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.
- {switchback-0.4.0 → switchback-0.6.0}/.env.example +21 -12
- {switchback-0.4.0 → switchback-0.6.0}/CHANGELOG.md +66 -1
- switchback-0.6.0/CONTRIBUTING.md +67 -0
- {switchback-0.4.0 → switchback-0.6.0}/PKG-INFO +161 -31
- {switchback-0.4.0 → switchback-0.6.0}/README.md +153 -29
- {switchback-0.4.0 → switchback-0.6.0}/pyproject.toml +13 -2
- {switchback-0.4.0 → switchback-0.6.0}/switchback/__init__.py +4 -2
- {switchback-0.4.0 → switchback-0.6.0}/switchback/api.py +60 -16
- {switchback-0.4.0 → switchback-0.6.0}/switchback/doctor.py +13 -10
- {switchback-0.4.0 → switchback-0.6.0}/switchback/flags.py +2 -2
- switchback-0.6.0/switchback/highlights.py +311 -0
- switchback-0.6.0/switchback/mcp_server.py +81 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/normalize.py +98 -1
- {switchback-0.4.0 → switchback-0.6.0}/switchback/orchestrator.py +117 -21
- {switchback-0.4.0 → switchback-0.6.0}/switchback/policy/botwall.py +36 -2
- {switchback-0.4.0 → switchback-0.6.0}/switchback/reporting.py +1 -1
- {switchback-0.4.0 → switchback-0.6.0}/switchback/server.py +9 -6
- switchback-0.6.0/switchback/state.py +115 -0
- switchback-0.6.0/switchback/tiers/__init__.py +24 -0
- switchback-0.6.0/switchback/tiers/_browser.py +142 -0
- switchback-0.6.0/switchback/tiers/tier_1.py +193 -0
- switchback-0.4.0/switchback/tiers/tier1_http.py → switchback-0.6.0/switchback/tiers/tier_2.py +29 -4
- switchback-0.4.0/switchback/tiers/tier2_cloudscraper.py → switchback-0.6.0/switchback/tiers/tier_3.py +21 -6
- switchback-0.4.0/switchback/tiers/tier3_browser.py → switchback-0.6.0/switchback/tiers/tier_4.py +7 -3
- switchback-0.4.0/switchback/tiers/tier3b_camoufox.py → switchback-0.6.0/switchback/tiers/tier_5.py +10 -3
- switchback-0.4.0/switchback/tiers/tier_residential.py → switchback-0.6.0/switchback/tiers/tier_6.py +8 -2
- switchback-0.4.0/switchback/tiers/tier4_firecrawl.py → switchback-0.6.0/switchback/tiers/tier_7.py +17 -5
- {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/PKG-INFO +161 -31
- {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/SOURCES.txt +10 -7
- {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/entry_points.txt +1 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/requires.txt +9 -0
- switchback-0.4.0/CONTRIBUTING.md +0 -35
- switchback-0.4.0/switchback/tiers/__init__.py +0 -24
- switchback-0.4.0/switchback/tiers/_browser.py +0 -50
- switchback-0.4.0/switchback/tiers/tier0_apis.py +0 -77
- {switchback-0.4.0 → switchback-0.6.0}/LICENSE +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/MANIFEST.in +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/NOTICE +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/SECURITY.md +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/clients/node_bridge.md +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/clients/python_client.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/config/botwall_skip_urls.txt +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/config/extraction.example.json +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/setup.cfg +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/__main__.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/concurrency.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/content_cache.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/egress.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/extract.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/policy/__init__.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/policy/gates.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/py.typed +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/search.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/session_cache.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/session_trace.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback/tracing.py +0 -0
- {switchback-0.4.0 → switchback-0.6.0}/switchback.egg-info/dependency_links.txt +0 -0
- {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 (
|
|
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
|
-
# ──
|
|
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
|
|
35
|
-
|
|
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
|
-
# ──
|
|
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
|
-
|
|
41
|
+
SCRAPER_TIER_5_TIMEOUT_S=45
|
|
47
42
|
|
|
48
|
-
# ──
|
|
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
|
-
## [
|
|
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.
|
|
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,
|
|
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)
|
|
81
89
|
[](https://github.com/akash-kr/switchback/actions/workflows/ci.yml)
|
|
82
90
|
|
|
91
|
+
⭐ If switchback saved you a paid-API bill, a star helps others find it.
|
|
92
|
+
|
|
83
93
|
</div>
|
|
84
94
|
|
|
85
95
|
---
|
|
@@ -117,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
|
-
|
|
|
125
|
-
|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
128
|
-
|
|
|
129
|
-
|
|
|
130
|
-
|
|
|
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 (
|
|
146
|
-
pip install "switchback[cloudflare]" # +
|
|
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 # +
|
|
149
|
-
pip install "switchback[camoufox]" && camoufox fetch # +
|
|
150
|
-
pip install "switchback[firecrawl]" # +
|
|
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]" #
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
-
|
|
177
|
-
- **
|
|
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.
|
|
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
|
|
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
|
|
184
|
-
`
|
|
185
|
-
|
|
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
|
|
286
|
-
- `FIRECRAWL_API_KEY` — enable
|
|
287
|
-
- `SCRAPER_DISABLE_CAMOUFOX` — turn off
|
|
288
|
-
- `
|
|
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
|
|
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
|
-
- `
|
|
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. `
|
|
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
|