switchback 0.5.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 (57) hide show
  1. {switchback-0.5.0 → switchback-0.6.0}/CHANGELOG.md +41 -1
  2. switchback-0.6.0/CONTRIBUTING.md +67 -0
  3. {switchback-0.5.0 → switchback-0.6.0}/PKG-INFO +135 -6
  4. {switchback-0.5.0 → switchback-0.6.0}/README.md +127 -4
  5. {switchback-0.5.0 → switchback-0.6.0}/pyproject.toml +13 -2
  6. {switchback-0.5.0 → switchback-0.6.0}/switchback/__init__.py +4 -2
  7. {switchback-0.5.0 → switchback-0.6.0}/switchback/api.py +60 -16
  8. {switchback-0.5.0 → switchback-0.6.0}/switchback/doctor.py +4 -1
  9. switchback-0.6.0/switchback/highlights.py +311 -0
  10. switchback-0.6.0/switchback/mcp_server.py +81 -0
  11. {switchback-0.5.0 → switchback-0.6.0}/switchback/normalize.py +98 -1
  12. {switchback-0.5.0 → switchback-0.6.0}/switchback/orchestrator.py +115 -19
  13. {switchback-0.5.0 → switchback-0.6.0}/switchback/server.py +9 -6
  14. switchback-0.6.0/switchback/state.py +115 -0
  15. switchback-0.6.0/switchback/tiers/_browser.py +142 -0
  16. switchback-0.6.0/switchback/tiers/tier_1.py +193 -0
  17. {switchback-0.5.0 → switchback-0.6.0}/switchback/tiers/tier_2.py +23 -2
  18. {switchback-0.5.0 → switchback-0.6.0}/switchback/tiers/tier_3.py +15 -3
  19. {switchback-0.5.0 → switchback-0.6.0}/switchback/tiers/tier_4.py +2 -1
  20. {switchback-0.5.0 → switchback-0.6.0}/switchback/tiers/tier_5.py +2 -1
  21. {switchback-0.5.0 → switchback-0.6.0}/switchback/tiers/tier_7.py +4 -2
  22. {switchback-0.5.0 → switchback-0.6.0}/switchback.egg-info/PKG-INFO +135 -6
  23. {switchback-0.5.0 → switchback-0.6.0}/switchback.egg-info/SOURCES.txt +3 -0
  24. {switchback-0.5.0 → switchback-0.6.0}/switchback.egg-info/entry_points.txt +1 -0
  25. {switchback-0.5.0 → switchback-0.6.0}/switchback.egg-info/requires.txt +9 -0
  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.0}/.env.example +0 -0
  30. {switchback-0.5.0 → switchback-0.6.0}/LICENSE +0 -0
  31. {switchback-0.5.0 → switchback-0.6.0}/MANIFEST.in +0 -0
  32. {switchback-0.5.0 → switchback-0.6.0}/NOTICE +0 -0
  33. {switchback-0.5.0 → switchback-0.6.0}/SECURITY.md +0 -0
  34. {switchback-0.5.0 → switchback-0.6.0}/clients/node_bridge.md +0 -0
  35. {switchback-0.5.0 → switchback-0.6.0}/clients/python_client.py +0 -0
  36. {switchback-0.5.0 → switchback-0.6.0}/config/botwall_skip_urls.txt +0 -0
  37. {switchback-0.5.0 → switchback-0.6.0}/config/extraction.example.json +0 -0
  38. {switchback-0.5.0 → switchback-0.6.0}/setup.cfg +0 -0
  39. {switchback-0.5.0 → switchback-0.6.0}/switchback/__main__.py +0 -0
  40. {switchback-0.5.0 → switchback-0.6.0}/switchback/concurrency.py +0 -0
  41. {switchback-0.5.0 → switchback-0.6.0}/switchback/content_cache.py +0 -0
  42. {switchback-0.5.0 → switchback-0.6.0}/switchback/egress.py +0 -0
  43. {switchback-0.5.0 → switchback-0.6.0}/switchback/extract.py +0 -0
  44. {switchback-0.5.0 → switchback-0.6.0}/switchback/flags.py +0 -0
  45. {switchback-0.5.0 → switchback-0.6.0}/switchback/policy/__init__.py +0 -0
  46. {switchback-0.5.0 → switchback-0.6.0}/switchback/policy/botwall.py +0 -0
  47. {switchback-0.5.0 → switchback-0.6.0}/switchback/policy/gates.py +0 -0
  48. {switchback-0.5.0 → switchback-0.6.0}/switchback/py.typed +0 -0
  49. {switchback-0.5.0 → switchback-0.6.0}/switchback/reporting.py +0 -0
  50. {switchback-0.5.0 → switchback-0.6.0}/switchback/search.py +0 -0
  51. {switchback-0.5.0 → switchback-0.6.0}/switchback/session_cache.py +0 -0
  52. {switchback-0.5.0 → switchback-0.6.0}/switchback/session_trace.py +0 -0
  53. {switchback-0.5.0 → switchback-0.6.0}/switchback/tiers/__init__.py +0 -0
  54. {switchback-0.5.0 → switchback-0.6.0}/switchback/tiers/tier_6.py +0 -0
  55. {switchback-0.5.0 → switchback-0.6.0}/switchback/tracing.py +0 -0
  56. {switchback-0.5.0 → switchback-0.6.0}/switchback.egg-info/dependency_links.txt +0 -0
  57. {switchback-0.5.0 → switchback-0.6.0}/switchback.egg-info/top_level.txt +0 -0
@@ -4,7 +4,47 @@ 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.
8
48
 
9
49
  ## [0.5.0] - 2026-06-30
10
50
 
@@ -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.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,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,6 +392,8 @@ 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))
@@ -313,6 +421,13 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
313
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
314
422
  - `SCRAPER_LOGIN_HOOK` — `pkg.module:func` returning `{cookie: value}` for a host (see [Logged-in sessions](#logged-in-sessions))
315
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
316
431
  - `SCRAPER_TRACE_SESSION` — opt-in: capture a Playwright trace (screenshots + DOM + network) per browser-tier attempt, written to `state/traces/`
317
432
  - `BENCH_FIRECRAWL_USD` / `BENCH_FIRECRAWL_HARD_MULT` — cost model for the savings report
318
433
  </details>
@@ -364,6 +479,20 @@ strip site-specific noise, declare prefs per host in `config/extraction.json`
364
479
  (see [config/extraction.example.json](config/extraction.example.json)); every
365
480
  tier's normalize step picks them up automatically.
366
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
+
367
496
  ## Contributing
368
497
 
369
498
  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,6 +327,8 @@ 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))
@@ -254,6 +356,13 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
254
356
  - `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
357
  - `SCRAPER_LOGIN_HOOK` — `pkg.module:func` returning `{cookie: value}` for a host (see [Logged-in sessions](#logged-in-sessions))
256
358
  - `SCRAPER_EXTRACTION_FILE` — per-domain extraction prefs JSON (default `config/extraction.json`)
359
+ - `SCRAPER_HIGHLIGHTS_GRADER` — `bm25` (default) or `jev` (needs `TYPESAFE_API_KEY`; see [Agent-ready extras](#agent-ready-extras))
360
+ - `SCRAPER_HIGHLIGHTS_TOP_K` — highlights returned per URL (5)
361
+ - `SCRAPER_HIGHLIGHTS_MIN_SCORE` — Jev probability below which a passage is dropped (0.5)
362
+ - `SCRAPER_JEV_MODEL` / `SCRAPER_JEV_TIMEOUT_S` — Jev model alias (`jev-latest`) and per-request timeout (20s)
363
+ - `SCRAPER_YOUTUBE` — opt-in: `1` returns YouTube transcripts from tier_1 (needs `switchback[youtube]`)
364
+ - `SCRAPER_DOCUMENTS` — opt-in: `1` converts .docx/.pptx/.xlsx/.xls/.epub to markdown in tier_2 (needs `switchback[documents]`)
365
+ - `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
366
  - `SCRAPER_TRACE_SESSION` — opt-in: capture a Playwright trace (screenshots + DOM + network) per browser-tier attempt, written to `state/traces/`
258
367
  - `BENCH_FIRECRAWL_USD` / `BENCH_FIRECRAWL_HARD_MULT` — cost model for the savings report
259
368
  </details>
@@ -305,6 +414,20 @@ strip site-specific noise, declare prefs per host in `config/extraction.json`
305
414
  (see [config/extraction.example.json](config/extraction.example.json)); every
306
415
  tier's normalize step picks them up automatically.
307
416
 
417
+ ## Roadmap
418
+
419
+ Not built yet:
420
+
421
+ - **Layout-aware markdown** — keep pricing grids and card layouts built from
422
+ `<div>`s (not `<table>`s) as tables, using the browser tiers' rendered layout.
423
+ - **Scanned pages and images** — OCR / vision-model text for PDFs with no text
424
+ layer and for images.
425
+ - **Audio and caption-less video** — speech-to-text transcription.
426
+ - **Hosted service** — managed browsers at high concurrency, stored results and
427
+ shareable result links. switchback stays a library you run yourself.
428
+ - Smaller gaps: Next.js App Router flight data (`self.__next_f`) in `state`, and
429
+ YouTube chapters in transcripts.
430
+
308
431
  ## Contributing
309
432
 
310
433
  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.0"
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",
@@ -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"
@@ -5,8 +5,10 @@ Public API:
5
5
  results = scrape(["https://example.com/article"])
6
6
  hits = search("web scraping") # query → URLs (SearXNG)
7
7
  """
8
- from .api import scrape, scrape_detailed, ScrapeOutcome, ScrapeResult, TierAttempt
8
+ from .api import (scrape, scrape_detailed, ScrapeOutcome, ScrapeResult, TierAttempt,
9
+ ScrapeOutcomeWithExtras, ScrapeResultWithExtras)
9
10
  from .search import search, SearchResult
10
11
 
11
12
  __all__ = ["scrape", "scrape_detailed", "ScrapeOutcome", "ScrapeResult",
12
- "TierAttempt", "search", "SearchResult"]
13
+ "TierAttempt", "search", "SearchResult",
14
+ "ScrapeOutcomeWithExtras", "ScrapeResultWithExtras"]