switchback 0.4.0__tar.gz → 0.6.0__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 (58) hide show
  1. {switchback-0.4.0 → switchback-0.6.0}/.env.example +21 -12
  2. {switchback-0.4.0 → switchback-0.6.0}/CHANGELOG.md +66 -1
  3. switchback-0.6.0/CONTRIBUTING.md +67 -0
  4. {switchback-0.4.0 → switchback-0.6.0}/PKG-INFO +161 -31
  5. {switchback-0.4.0 → switchback-0.6.0}/README.md +153 -29
  6. {switchback-0.4.0 → switchback-0.6.0}/pyproject.toml +13 -2
  7. {switchback-0.4.0 → switchback-0.6.0}/switchback/__init__.py +4 -2
  8. {switchback-0.4.0 → switchback-0.6.0}/switchback/api.py +60 -16
  9. {switchback-0.4.0 → switchback-0.6.0}/switchback/doctor.py +13 -10
  10. {switchback-0.4.0 → switchback-0.6.0}/switchback/flags.py +2 -2
  11. switchback-0.6.0/switchback/highlights.py +311 -0
  12. switchback-0.6.0/switchback/mcp_server.py +81 -0
  13. {switchback-0.4.0 → switchback-0.6.0}/switchback/normalize.py +98 -1
  14. {switchback-0.4.0 → switchback-0.6.0}/switchback/orchestrator.py +117 -21
  15. {switchback-0.4.0 → switchback-0.6.0}/switchback/policy/botwall.py +36 -2
  16. {switchback-0.4.0 → switchback-0.6.0}/switchback/reporting.py +1 -1
  17. {switchback-0.4.0 → switchback-0.6.0}/switchback/server.py +9 -6
  18. switchback-0.6.0/switchback/state.py +115 -0
  19. switchback-0.6.0/switchback/tiers/__init__.py +24 -0
  20. switchback-0.6.0/switchback/tiers/_browser.py +142 -0
  21. switchback-0.6.0/switchback/tiers/tier_1.py +193 -0
  22. switchback-0.4.0/switchback/tiers/tier1_http.py → switchback-0.6.0/switchback/tiers/tier_2.py +29 -4
  23. switchback-0.4.0/switchback/tiers/tier2_cloudscraper.py → switchback-0.6.0/switchback/tiers/tier_3.py +21 -6
  24. switchback-0.4.0/switchback/tiers/tier3_browser.py → switchback-0.6.0/switchback/tiers/tier_4.py +7 -3
  25. switchback-0.4.0/switchback/tiers/tier3b_camoufox.py → switchback-0.6.0/switchback/tiers/tier_5.py +10 -3
  26. switchback-0.4.0/switchback/tiers/tier_residential.py → switchback-0.6.0/switchback/tiers/tier_6.py +8 -2
  27. switchback-0.4.0/switchback/tiers/tier4_firecrawl.py → switchback-0.6.0/switchback/tiers/tier_7.py +17 -5
  28. {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/PKG-INFO +161 -31
  29. {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/SOURCES.txt +10 -7
  30. {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/entry_points.txt +1 -0
  31. {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/requires.txt +9 -0
  32. switchback-0.4.0/CONTRIBUTING.md +0 -35
  33. switchback-0.4.0/switchback/tiers/__init__.py +0 -24
  34. switchback-0.4.0/switchback/tiers/_browser.py +0 -50
  35. switchback-0.4.0/switchback/tiers/tier0_apis.py +0 -77
  36. {switchback-0.4.0 → switchback-0.6.0}/LICENSE +0 -0
  37. {switchback-0.4.0 → switchback-0.6.0}/MANIFEST.in +0 -0
  38. {switchback-0.4.0 → switchback-0.6.0}/NOTICE +0 -0
  39. {switchback-0.4.0 → switchback-0.6.0}/SECURITY.md +0 -0
  40. {switchback-0.4.0 → switchback-0.6.0}/clients/node_bridge.md +0 -0
  41. {switchback-0.4.0 → switchback-0.6.0}/clients/python_client.py +0 -0
  42. {switchback-0.4.0 → switchback-0.6.0}/config/botwall_skip_urls.txt +0 -0
  43. {switchback-0.4.0 → switchback-0.6.0}/config/extraction.example.json +0 -0
  44. {switchback-0.4.0 → switchback-0.6.0}/setup.cfg +0 -0
  45. {switchback-0.4.0 → switchback-0.6.0}/switchback/__main__.py +0 -0
  46. {switchback-0.4.0 → switchback-0.6.0}/switchback/concurrency.py +0 -0
  47. {switchback-0.4.0 → switchback-0.6.0}/switchback/content_cache.py +0 -0
  48. {switchback-0.4.0 → switchback-0.6.0}/switchback/egress.py +0 -0
  49. {switchback-0.4.0 → switchback-0.6.0}/switchback/extract.py +0 -0
  50. {switchback-0.4.0 → switchback-0.6.0}/switchback/policy/__init__.py +0 -0
  51. {switchback-0.4.0 → switchback-0.6.0}/switchback/policy/gates.py +0 -0
  52. {switchback-0.4.0 → switchback-0.6.0}/switchback/py.typed +0 -0
  53. {switchback-0.4.0 → switchback-0.6.0}/switchback/search.py +0 -0
  54. {switchback-0.4.0 → switchback-0.6.0}/switchback/session_cache.py +0 -0
  55. {switchback-0.4.0 → switchback-0.6.0}/switchback/session_trace.py +0 -0
  56. {switchback-0.4.0 → switchback-0.6.0}/switchback/tracing.py +0 -0
  57. {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/dependency_links.txt +0 -0
  58. {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/top_level.txt +0 -0
@@ -11,7 +11,7 @@
11
11
  OTEL_SERVICE_NAME=switchback
12
12
  OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
13
13
 
14
- # ── Search (Tier-0 SearXNG, query → URLs) ───────────────────────────────────
14
+ # ── Search (SearXNG, query → URLs — separate from the fetch cascade) ─────────
15
15
  SEARXNG_URL=http://localhost:8888
16
16
 
17
17
  # ── Output format ───────────────────────────────────────────────────────────
@@ -27,29 +27,38 @@ SEARXNG_URL=http://localhost:8888
27
27
  # fall back to their text for those sources.
28
28
  SCRAPER_OUTPUT_FORMAT=markdown
29
29
 
30
- # ── Tier 2 · Cloudflare solver (cloudscraper) ───────────────────────────────
30
+ # ── tier_3 · Cloudflare solver (cloudscraper) ───────────────────────────────
31
31
  # Needs the 3.x Enhanced Edition fork (see README); with the frozen PyPI build
32
32
  # the tier reports `unavailable` and fails fast. Wall-clock cap on a single solve
33
33
  # so an unsolvable challenge can't eat the per-URL deadline before the browser
34
- # tier runs. Lower (e.g. 12) if Tier 2 rarely wins on your hosts.
35
- SCRAPER_CLOUDSCRAPER_TIMEOUT_S=25
34
+ # tier runs. Lower (e.g. 12) if tier_3 rarely wins on your hosts.
35
+ SCRAPER_TIER_3_TIMEOUT_S=25
36
36
 
37
- # ── Tier 2.5 · Jina Reader (r.jina.ai) ──────────────────────────────────────
38
- # Optional: keyless works at 20 RPM. A key gives 500 RPM + a 10M-token grant.
39
- JINA_API_KEY=
40
- SCRAPER_JINA_TIMEOUT_S=20
41
-
42
- # ── Tier 3b · Camoufox (Firefox stealth) ────────────────────────────────────
37
+ # ── tier_5 · Camoufox (Firefox stealth) ─────────────────────────────────────
43
38
  # ON by default. Needs: pip install camoufox && camoufox fetch
44
39
  # Set to 1 to turn the tier off entirely.
45
40
  SCRAPER_DISABLE_CAMOUFOX=
46
- SCRAPER_CAMOUFOX_TIMEOUT_MS=45000
41
+ SCRAPER_TIER_5_TIMEOUT_S=45
47
42
 
48
- # ── Tier 4 · Firecrawl (paid, last resort) ──────────────────────────────────
43
+ # ── tier_7 · Firecrawl (paid, last resort) ──────────────────────────────────
49
44
  # Required only if this tier runs. Set SCRAPER_DISABLE_FIRECRAWL=1 to skip it.
50
45
  FIRECRAWL_API_KEY=
51
46
  SCRAPER_DISABLE_FIRECRAWL=
52
47
 
48
+ # ── Per-tier timeouts (seconds) ─────────────────────────────────────────────
49
+ # Each tier's wall-clock/socket cap; override any of them. Defaults shown below.
50
+ # tier_3 (=25) and tier_5 (=45) are set live in their sections above; the rest
51
+ # fall back to these defaults. The pre-0.5.0 names are still honored when the new
52
+ # var is unset: SCRAPER_CLOUDSCRAPER_TIMEOUT_S → tier_3,
53
+ # SCRAPER_CAMOUFOX_TIMEOUT_MS → tier_5, SCRAPER_RESIDENTIAL_TIMEOUT_MS → tier_6.
54
+ #SCRAPER_TIER_1_TIMEOUT_S=15 # direct APIs / open mirrors
55
+ #SCRAPER_TIER_2_TIMEOUT_S=15 # plain HTTP + TLS impersonation
56
+ #SCRAPER_TIER_3_TIMEOUT_S=25 # cloudscraper (Cloudflare solver)
57
+ #SCRAPER_TIER_4_TIMEOUT_S=15 # stealth headless browser (patchright)
58
+ #SCRAPER_TIER_5_TIMEOUT_S=45 # camoufox (slowest rung; hard CF solves ~40s)
59
+ #SCRAPER_TIER_6_TIMEOUT_S=30 # residential-IP CDP browser
60
+ #SCRAPER_TIER_7_TIMEOUT_S=15 # Firecrawl (paid) — was unbounded; raise if scrapes get cut off
61
+
53
62
  # ── Orchestrator ────────────────────────────────────────────────────────────
54
63
  # Per-URL wall-clock budget (s), checked between tiers. 45s balances latency vs
55
64
  # coverage — roughly fits a Camoufox solve (~40s) that starts after the cheaper
@@ -4,7 +4,72 @@ 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.0] - 2026-10-14
8
+
9
+ ### Added
10
+ - **`SCRAPER_DISABLE_CLOUDSCRAPER`** — opt-out for tier_3. Solving a JS challenge
11
+ runs site-supplied JavaScript in js2py or Node's `vm`, neither of which is a
12
+ security boundary; deployments that scrape URLs from untrusted users should turn
13
+ it off and let the browser tiers clear Cloudflare. Unset = unchanged. A host whose
14
+ learned winner was tier_3 falls through to the next tier rather than stalling,
15
+ and `--doctor` shows tier_3 as `off` without failing the healthcheck.
16
+ - **`SCRAPER_BROWSER_BLOCK_PRIVATE`** — opt-in guard for tiers 4 and 5: every
17
+ request and WebSocket a page makes to a loopback, private, link-local (incl.
18
+ cloud metadata) or CGNAT address is refused, and service workers are blocked so
19
+ they can't bypass it. Unset = unchanged (no routing is installed at all).
20
+ - **Agent-ready extras, all opt-in** (`scrape(..., state=, appendix=, query=)`,
21
+ the same flags on `scrape_detailed`, `--state` / `--appendix` / `--query` on the
22
+ CLI, and `state` / `appendix` / `query` in the HTTP `/scrape` body). Without them
23
+ results, CLI JSON and HTTP JSON are unchanged: `ScrapeResult` and `ScrapeOutcome`
24
+ keep exactly their fields. With them, results are the new subclasses
25
+ `ScrapeResultWithExtras` / `ScrapeOutcomeWithExtras`.
26
+ - `state` — the page's embedded JSON (Next.js `__NEXT_DATA__`, Nuxt,
27
+ `window.__X__` globals, JSON-LD).
28
+ - `appendix` — nav/header/footer/aside as separate markdown regions.
29
+ - `query` → `highlights` — the passages that answer the question, each with a
30
+ text-fragment citation link. Graded by local BM25 by default, or by TypeSafe's
31
+ Jev model with `SCRAPER_HIGHLIGHTS_GRADER=jev` + `TYPESAFE_API_KEY` (falls back
32
+ to BM25 on any Jev error).
33
+ - **MCP server** — `switchback-mcp` (stdio) exposes `scrape` and `search` as tools.
34
+ Install with `switchback[mcp]`.
35
+ - **YouTube transcripts** in tier_1 (title, channel, timestamped transcript linked
36
+ to the moment in the video). Opt in with `SCRAPER_YOUTUBE=1` plus
37
+ `switchback[youtube]`; otherwise, or for videos with no captions, YouTube URLs
38
+ go through the cascade as before.
39
+ YouTube blocks most cloud IPs; a blocked request is retried once through
40
+ `SCRAPER_EGRESS_PROXY` when one is configured. Transcript highlights cite the
41
+ timestamp in the video.
42
+ - **Office and ebook files** (.docx, .pptx, .xlsx, .xls, .epub) converted to
43
+ markdown in tier_2 via markitdown. Opt in with `SCRAPER_DOCUMENTS=1` plus
44
+ `switchback[documents]`; otherwise these URLs are handled exactly as before.
45
+
46
+ The new extras are deliberately not added to `[all]`, so upgrading an existing
47
+ `[all]` install pulls no new dependencies.
48
+
49
+ ## [0.5.0] - 2026-06-30
50
+
51
+ ### Changed
52
+ - **Tiers renamed to plain `tier_1`…`tier_7`** (cost-ordered, contiguous) in place
53
+ of the old mixed scheme (`tier0_apis`, `tier1_http`, `tier2_cloudscraper`,
54
+ `tier3_browser`, `tier3b_camoufox`, `tier_residential`, `tier4_firecrawl`). The
55
+ mapping is positional: `tier_1`=apis, `tier_2`=http, `tier_3`=cloudscraper,
56
+ `tier_4`=browser, `tier_5`=camoufox, `tier_6`=residential, `tier_7`=firecrawl.
57
+ **Backwards-compatible:** an existing `state/botwall_db.json` is migrated on load
58
+ (a host's learned `winning_tier` / `tier_stats` keys are remapped to the new
59
+ names), so routing survives the upgrade instead of re-probing from scratch.
60
+
61
+ ### Added
62
+ - **Per-tier timeout knobs** — every tier now reads `SCRAPER_TIER_<N>_TIMEOUT_S`
63
+ (seconds, `N` = 1–7). Defaults: `15` for tiers without a prior budget
64
+ (apis/http/browser), and the existing budgets are preserved — `tier_3`=25,
65
+ `tier_5`=45, `tier_6`=30. The previously-unconfigurable/unbounded tiers (apis,
66
+ http, browser, **firecrawl**) are now bounded and overridable. The pre-rename
67
+ `SCRAPER_CLOUDSCRAPER_TIMEOUT_S` / `SCRAPER_CAMOUFOX_TIMEOUT_MS` /
68
+ `SCRAPER_RESIDENTIAL_TIMEOUT_MS` are still honored when the new var is unset.
69
+ Note: `tier_7` (paid Firecrawl) was previously unbounded; its new `15`s default
70
+ bounds it — raise `SCRAPER_TIER_7_TIMEOUT_S` if hard hosts get cut off (a scrape
71
+ killed at the cap may still be billed). `SCRAPER_TIER_RETRIES_<TIER>` overrides
72
+ follow the new names (e.g. `SCRAPER_TIER_RETRIES_TIER_4`).
8
73
 
9
74
  ## [0.4.0] - 2026-06-29
10
75
 
@@ -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.4.0
3
+ Version: 0.6.0
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
@@ -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,17 +127,25 @@ 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
- | 0 | Direct APIs / mirrors (arxiv, wikipedia, EuropePMC; extend: job boards) | free, cleanest |
125
- | 1 | Plain HTTP + TLS impersonation (`curl_cffi`), incl. PDFs | cheap |
126
- | 2 | Cloudflare / anti-bot solver (`cloudscraper`, install `.[cloudflare]`) | cheap-ish (~5s/host) |
127
- | 3 | Stealth headless browser (`patchright`, Chromium) | heavy |
128
- | 3b | Camoufox (Firefox stealth) — **on by default** (opt out: `SCRAPER_DISABLE_CAMOUFOX`) | heavy + slow (~40s on hard CF) |
129
- | 3c | Residential-IP browser over CDP (`BU_CDP_URL`) — off unless configured | heavy (remote egress) |
130
- | 4 | Firecrawl (paid, env-gated, audited) | paid, last resort |
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) |
145
+ | tier_4 | Stealth headless browser (`patchright`, Chromium) | heavy |
146
+ | tier_5 | Camoufox (Firefox stealth) — **on by default** (opt out: `SCRAPER_DISABLE_CAMOUFOX`) | heavy + slow (~40s on hard CF) |
147
+ | tier_6 | Residential-IP browser over CDP (`BU_CDP_URL`) — off unless configured | heavy (remote egress) |
148
+ | tier_7 | Firecrawl (paid, env-gated, audited) | paid, last resort |
131
149
 
132
150
  Every URL has a wall-clock budget (`SCRAPER_DEADLINE_S`, default 45s) checked between
133
151
  tiers so one URL can't run the whole cascade of timeouts. Each tier attempt records
@@ -142,17 +160,26 @@ Search (query → URLs) is separate from the scrape cascade: `switchback.search(
142
160
  ## Install
143
161
 
144
162
  ```bash
145
- pip install switchback # core: normalization + cheap tiers (0/1) + search
146
- pip install "switchback[cloudflare]" # + Tier 2 Cloudflare/anti-bot solver (cloudscraper)
163
+ pip install switchback # core: normalization + cheap tiers (tier_1/tier_2) + search
164
+ pip install "switchback[cloudflare]" # + tier_3 Cloudflare/anti-bot solver (cloudscraper)
147
165
  pip install "switchback[server]" # + HTTP service (fastapi, uvicorn) incl. /metrics + /traces
148
- pip install "switchback[browser]" && patchright install chromium # + Tier 3 stealth Chromium
149
- pip install "switchback[camoufox]" && camoufox fetch # + Tier 3b Firefox stealth
150
- pip install "switchback[firecrawl]" # + Tier 4 paid API (needs FIRECRAWL_API_KEY)
166
+ pip install "switchback[browser]" && patchright install chromium # + tier_4 stealth Chromium
167
+ pip install "switchback[camoufox]" && camoufox fetch # + tier_5 Firefox stealth
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
 
155
- For Tier 2's **full** v3 JS-VM + Turnstile + stealth, install the Enhanced Edition
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
+
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):
158
185
 
@@ -170,19 +197,44 @@ and land *after* boot (e.g. an async install thread on Azure). Until they're
170
197
  ready, those tiers report **`unavailable`** (a distinct outcome carrying the exact
171
198
  fix) and the cascade falls through — they are never silently skipped. Checklist:
172
199
 
173
- - **Tier 3 is the real workhorse for Cloudflare/JS sites** — make sure its browser
200
+ - **tier_4 is the real workhorse for Cloudflare/JS sites** — make sure its browser
174
201
  is installed: `patchright install chromium` (note: **patchright**, not vanilla
175
202
  `playwright`). On a cold start, run this in your post-boot install step/thread;
176
- Tier 3 flips to ready once it finishes.
177
- - **Tier 2 needs the cloudscraper 3.x fork** (above) to attempt stealth. With the
203
+ tier_4 flips to ready once it finishes.
204
+ - **tier_3 needs the cloudscraper 3.x fork** (above) to attempt stealth. With the
178
205
  frozen PyPI `cloudscraper` it reports `unavailable` and fails fast (no wasted
179
- solve budget) instead of erroring mid-cascade. Tier 2 is a *weak* solver for
206
+ solve budget) instead of erroring mid-cascade. tier_3 is a *weak* solver for
180
207
  modern Cloudflare — treat it as a cheap try before the browser, not the primary.
181
- - **Install Node.js** for Tier 2's v3 JS-VM challenges — faster and thread-safe
208
+ - **Install Node.js** for tier_3's v3 JS-VM challenges — faster and thread-safe
182
209
  vs. the pure-Python js2py fallback (relevant under concurrent load).
183
- - **Bound Tier 2's solve budget** with `SCRAPER_CLOUDSCRAPER_TIMEOUT_S` (default
184
- `25`) so an unsolvable challenge can't eat the per-URL deadline before the
185
- browser tier runs. Lower it (e.g. `12`) if Tier 2 rarely wins on your hosts.
210
+ - **Bound tier_3's solve budget** with `SCRAPER_TIER_3_TIMEOUT_S` (default `25`;
211
+ the old `SCRAPER_CLOUDSCRAPER_TIMEOUT_S` is still honored) so an unsolvable
212
+ challenge can't eat the per-URL deadline before the browser tier runs. Lower it
213
+ (e.g. `12`) if tier_3 rarely wins on your hosts.
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.
186
238
 
187
239
  **Verify readiness on the box** with the preflight check (doubles as a healthcheck
188
240
  — exit 0 when the capable tiers are ready):
@@ -226,6 +278,61 @@ Non-Python callers: see [clients/node_bridge.md](clients/node_bridge.md). Python
226
278
  callers that want HTTP-with-CLI-fallback can drop in
227
279
  [clients/python_client.py](clients/python_client.py).
228
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
+
229
336
  ## Cost-scoped residential egress
230
337
 
231
338
  The dominant reason hard hosts wall you is the **datacenter IP**, not the
@@ -282,16 +389,18 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
282
389
  <details>
283
390
  <summary><b>Env gates</b> — enable/disable tiers and integrations</summary>
284
391
 
285
- - `SCRAPER_DISABLE_FIRECRAWL` — skip Tier 4
286
- - `FIRECRAWL_API_KEY` — enable Tier 4
287
- - `SCRAPER_DISABLE_CAMOUFOX` — turn off Tier 3b (on by default; needs `pip install camoufox` + `camoufox fetch`)
288
- - `BU_CDP_URL` — enable Tier 3c residential browser by pointing at a CDP endpoint
392
+ - `SCRAPER_DISABLE_FIRECRAWL` — skip tier_7
393
+ - `FIRECRAWL_API_KEY` — enable tier_7
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
397
+ - `BU_CDP_URL` — enable tier_6 residential browser by pointing at a CDP endpoint
289
398
  - `SCRAPER_PROXY` — route *all* tiers/URLs through a proxy
290
399
  - `SCRAPER_EGRESS_PROXY` — route only walled hosts through a proxy (see [Cost-scoped residential egress](#cost-scoped-residential-egress))
291
400
  - `SEARXNG_URL` — defaults to `http://localhost:8888`
292
401
  - `SCRAPER_STATE_DIR` — where the botwall DB/event log + session cache live
293
402
  - `SCRAPER_COOKIES_FILE` — Netscape `cookies.txt` to scrape login-gated hosts (injected into the HTTP and browser tiers)
294
- - `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 2 for Turnstile/reCAPTCHA/hCaptcha on CF hosts. **Paid**, billed per solve by the provider.
403
+ - `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.
295
404
  </details>
296
405
 
297
406
  <details>
@@ -300,7 +409,7 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
300
409
  - `SCRAPER_OUTPUT_FORMAT` — output shape: `markdown` (default) · `markdown_trimmed` · `html` · `html_selectors` (see [Output formats](#output-formats))
301
410
  - `SCRAPER_DEADLINE_S` — per-URL budget (45s)
302
411
  - `SCRAPER_FIRECRAWL_FALLBACK_AFTER_S` — after this many seconds on a URL, stop trying the local tiers and fall back to Firecrawl, so a hard host doesn't burn the whole deadline before the paid last resort gets a turn (25s; 0 = off)
303
- - `SCRAPER_CAMOUFOX_TIMEOUT_MS` — (45000)
412
+ - `SCRAPER_TIER_<N>_TIMEOUT_S` — per-tier timeout in seconds, `N` = 1–7 (tier_1 apis · tier_2 http · tier_3 cloudscraper · tier_4 browser · tier_5 camoufox · tier_6 residential · tier_7 firecrawl). Defaults: 15/15/**25**/15/**45**/**30**/15 (the three bold ones keep their prior budgets; everything else is 15s). The pre-0.5.0 `SCRAPER_CLOUDSCRAPER_TIMEOUT_S` / `SCRAPER_CAMOUFOX_TIMEOUT_MS` / `SCRAPER_RESIDENTIAL_TIMEOUT_MS` are still honored when the new var is unset. Note: `tier_7` (Firecrawl) was previously unbounded — its 15s default now bounds the paid tier, so raise `SCRAPER_TIER_7_TIMEOUT_S` if slow hosts get cut off
304
413
  - `SCRAPER_BROWSER_CONCURRENCY` — max simultaneous headless browsers (default 1)
305
414
  - `SCRAPER_BOTWALL_URL_SKIP_COOLDOWN_H` — auto-skip re-test window (24h; 0 = never)
306
415
  - `SCRAPER_BOTWALL_EGRESS_AFTER` — local-tier failures before a host escalates to the residential tier (default 2)
@@ -308,10 +417,17 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
308
417
  - `SCRAPER_DISABLE_SESSION_CACHE` — turn off cf_clearance reuse
309
418
  - `SCRAPER_CONTENT_TTL_S` — URL→result cache TTL (**0 = off**; set e.g. 86400 to skip re-scraping a page within a day)
310
419
  - `SCRAPER_BACKOFF_BASE_MS` / `SCRAPER_BACKOFF_MAX_MS` — exponential backoff between tiers after a rate-limit/timeout (base 0 = off)
311
- - `SCRAPER_TIER_RETRIES` — same-tier retries before falling through (default 0 = off; `N` → up to `1+N` tries per tier), with per-tier overrides `SCRAPER_TIER_RETRIES_<TIER>` (e.g. `SCRAPER_TIER_RETRIES_TIER3_BROWSER=2`)
420
+ - `SCRAPER_TIER_RETRIES` — same-tier retries before falling through (default 0 = off; `N` → up to `1+N` tries per tier), with per-tier overrides `SCRAPER_TIER_RETRIES_<TIER>` (e.g. `SCRAPER_TIER_RETRIES_TIER_4=2`)
312
421
  - `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
313
422
  - `SCRAPER_LOGIN_HOOK` — `pkg.module:func` returning `{cookie: value}` for a host (see [Logged-in sessions](#logged-in-sessions))
314
423
  - `SCRAPER_EXTRACTION_FILE` — per-domain extraction prefs JSON (default `config/extraction.json`)
424
+ - `SCRAPER_HIGHLIGHTS_GRADER` — `bm25` (default) or `jev` (needs `TYPESAFE_API_KEY`; see [Agent-ready extras](#agent-ready-extras))
425
+ - `SCRAPER_HIGHLIGHTS_TOP_K` — highlights returned per URL (5)
426
+ - `SCRAPER_HIGHLIGHTS_MIN_SCORE` — Jev probability below which a passage is dropped (0.5)
427
+ - `SCRAPER_JEV_MODEL` / `SCRAPER_JEV_TIMEOUT_S` — Jev model alias (`jev-latest`) and per-request timeout (20s)
428
+ - `SCRAPER_YOUTUBE` — opt-in: `1` returns YouTube transcripts from tier_1 (needs `switchback[youtube]`)
429
+ - `SCRAPER_DOCUMENTS` — opt-in: `1` converts .docx/.pptx/.xlsx/.xls/.epub to markdown in tier_2 (needs `switchback[documents]`)
430
+ - `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
315
431
  - `SCRAPER_TRACE_SESSION` — opt-in: capture a Playwright trace (screenshots + DOM + network) per browser-tier attempt, written to `state/traces/`
316
432
  - `BENCH_FIRECRAWL_USD` / `BENCH_FIRECRAWL_HARD_MULT` — cost model for the savings report
317
433
  </details>
@@ -363,6 +479,20 @@ strip site-specific noise, declare prefs per host in `config/extraction.json`
363
479
  (see [config/extraction.example.json](config/extraction.example.json)); every
364
480
  tier's normalize step picks them up automatically.
365
481
 
482
+ ## Roadmap
483
+
484
+ Not built yet:
485
+
486
+ - **Layout-aware markdown** — keep pricing grids and card layouts built from
487
+ `<div>`s (not `<table>`s) as tables, using the browser tiers' rendered layout.
488
+ - **Scanned pages and images** — OCR / vision-model text for PDFs with no text
489
+ layer and for images.
490
+ - **Audio and caption-less video** — speech-to-text transcription.
491
+ - **Hosted service** — managed browsers at high concurrency, stored results and
492
+ shareable result links. switchback stays a library you run yourself.
493
+ - Smaller gaps: Next.js App Router flight data (`self.__next_f`) in `state`, and
494
+ YouTube chapters in transcripts.
495
+
366
496
  ## Contributing
367
497
 
368
498
  Issues and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Start with the