switchback 0.6.0__tar.gz → 0.6.2__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 (54) hide show
  1. {switchback-0.6.0 → switchback-0.6.2}/CHANGELOG.md +31 -0
  2. {switchback-0.6.0 → switchback-0.6.2}/PKG-INFO +5 -4
  3. {switchback-0.6.0 → switchback-0.6.2}/README.md +1 -0
  4. {switchback-0.6.0 → switchback-0.6.2}/pyproject.toml +4 -4
  5. {switchback-0.6.0 → switchback-0.6.2}/switchback/policy/gates.py +18 -3
  6. {switchback-0.6.0 → switchback-0.6.2}/switchback/tiers/tier_1.py +4 -2
  7. {switchback-0.6.0 → switchback-0.6.2}/switchback/tiers/tier_2.py +1 -1
  8. {switchback-0.6.0 → switchback-0.6.2}/switchback/tiers/tier_5.py +10 -8
  9. {switchback-0.6.0 → switchback-0.6.2}/switchback.egg-info/PKG-INFO +5 -4
  10. {switchback-0.6.0 → switchback-0.6.2}/switchback.egg-info/requires.txt +3 -3
  11. {switchback-0.6.0 → switchback-0.6.2}/.env.example +0 -0
  12. {switchback-0.6.0 → switchback-0.6.2}/CONTRIBUTING.md +0 -0
  13. {switchback-0.6.0 → switchback-0.6.2}/LICENSE +0 -0
  14. {switchback-0.6.0 → switchback-0.6.2}/MANIFEST.in +0 -0
  15. {switchback-0.6.0 → switchback-0.6.2}/NOTICE +0 -0
  16. {switchback-0.6.0 → switchback-0.6.2}/SECURITY.md +0 -0
  17. {switchback-0.6.0 → switchback-0.6.2}/clients/node_bridge.md +0 -0
  18. {switchback-0.6.0 → switchback-0.6.2}/clients/python_client.py +0 -0
  19. {switchback-0.6.0 → switchback-0.6.2}/config/botwall_skip_urls.txt +0 -0
  20. {switchback-0.6.0 → switchback-0.6.2}/config/extraction.example.json +0 -0
  21. {switchback-0.6.0 → switchback-0.6.2}/setup.cfg +0 -0
  22. {switchback-0.6.0 → switchback-0.6.2}/switchback/__init__.py +0 -0
  23. {switchback-0.6.0 → switchback-0.6.2}/switchback/__main__.py +0 -0
  24. {switchback-0.6.0 → switchback-0.6.2}/switchback/api.py +0 -0
  25. {switchback-0.6.0 → switchback-0.6.2}/switchback/concurrency.py +0 -0
  26. {switchback-0.6.0 → switchback-0.6.2}/switchback/content_cache.py +0 -0
  27. {switchback-0.6.0 → switchback-0.6.2}/switchback/doctor.py +0 -0
  28. {switchback-0.6.0 → switchback-0.6.2}/switchback/egress.py +0 -0
  29. {switchback-0.6.0 → switchback-0.6.2}/switchback/extract.py +0 -0
  30. {switchback-0.6.0 → switchback-0.6.2}/switchback/flags.py +0 -0
  31. {switchback-0.6.0 → switchback-0.6.2}/switchback/highlights.py +0 -0
  32. {switchback-0.6.0 → switchback-0.6.2}/switchback/mcp_server.py +0 -0
  33. {switchback-0.6.0 → switchback-0.6.2}/switchback/normalize.py +0 -0
  34. {switchback-0.6.0 → switchback-0.6.2}/switchback/orchestrator.py +0 -0
  35. {switchback-0.6.0 → switchback-0.6.2}/switchback/policy/__init__.py +0 -0
  36. {switchback-0.6.0 → switchback-0.6.2}/switchback/policy/botwall.py +0 -0
  37. {switchback-0.6.0 → switchback-0.6.2}/switchback/py.typed +0 -0
  38. {switchback-0.6.0 → switchback-0.6.2}/switchback/reporting.py +0 -0
  39. {switchback-0.6.0 → switchback-0.6.2}/switchback/search.py +0 -0
  40. {switchback-0.6.0 → switchback-0.6.2}/switchback/server.py +0 -0
  41. {switchback-0.6.0 → switchback-0.6.2}/switchback/session_cache.py +0 -0
  42. {switchback-0.6.0 → switchback-0.6.2}/switchback/session_trace.py +0 -0
  43. {switchback-0.6.0 → switchback-0.6.2}/switchback/state.py +0 -0
  44. {switchback-0.6.0 → switchback-0.6.2}/switchback/tiers/__init__.py +0 -0
  45. {switchback-0.6.0 → switchback-0.6.2}/switchback/tiers/_browser.py +0 -0
  46. {switchback-0.6.0 → switchback-0.6.2}/switchback/tiers/tier_3.py +0 -0
  47. {switchback-0.6.0 → switchback-0.6.2}/switchback/tiers/tier_4.py +0 -0
  48. {switchback-0.6.0 → switchback-0.6.2}/switchback/tiers/tier_6.py +0 -0
  49. {switchback-0.6.0 → switchback-0.6.2}/switchback/tiers/tier_7.py +0 -0
  50. {switchback-0.6.0 → switchback-0.6.2}/switchback/tracing.py +0 -0
  51. {switchback-0.6.0 → switchback-0.6.2}/switchback.egg-info/SOURCES.txt +0 -0
  52. {switchback-0.6.0 → switchback-0.6.2}/switchback.egg-info/dependency_links.txt +0 -0
  53. {switchback-0.6.0 → switchback-0.6.2}/switchback.egg-info/entry_points.txt +0 -0
  54. {switchback-0.6.0 → switchback-0.6.2}/switchback.egg-info/top_level.txt +0 -0
@@ -4,6 +4,37 @@ 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
+ ## [0.6.2] - 2026-10-14
8
+
9
+ ### Fixed
10
+ - **Block pages hidden behind inline images were returned as successes.** A
11
+ bot-wall page that embeds its logo as a base64 `data:` image (e.g. Indeed's
12
+ Cloudflare block page, ~7k chars of SVG before "You have been blocked") pushed
13
+ the block phrase past the 600-char head scan *and* cleared the 2000-char length
14
+ floor on image bytes alone, so a browser tier reported `OK` with the block page
15
+ as content. The quality gates now ignore inline `data:` payloads when scanning
16
+ and measuring (returned markdown is unchanged), and Cloudflare's WAF copy "you
17
+ have been blocked" is a recognised wall (vendor `cloudflare`).
18
+
19
+ ## [0.6.1] - 2026-10-14
20
+
21
+ ### Fixed
22
+ - **camoufox geoip on fresh installs.** camoufox 0.5 swapped `geoip2` for
23
+ `maxminddb` in its `[geoip]` extra, so tier_5 decided geoip was missing and ran
24
+ proxied sessions without matching locale/timezone. It now asks camoufox's own
25
+ flag, which works on 0.4 and 0.5.
26
+
27
+ ### Changed
28
+ - **Newer Chrome TLS fingerprints in tier_2** (`chrome142`, `chrome145`,
29
+ `chrome146` instead of `chrome131`, `chrome136`, `chrome142`); two-year-old
30
+ Chrome versions are a fingerprint tell. Needs `curl_cffi>=0.15`, now the floor.
31
+ - **Security floors:** `pypdf>=6.19` (older releases have DoS CVEs on crafted
32
+ PDFs, which this library parses from the web) and `requests>=2.33`
33
+ (CVE-2024-35195, CVE-2024-47081, CVE-2026-25645).
34
+ - **arXiv calls no longer send the maintainer's email.** tier_1's arXiv
35
+ User-Agent named a fixed contact address on every install; it now names the
36
+ project URL, and `SCRAPER_ARXIV_CONTACT` adds your own address if you want one.
37
+
7
38
  ## [0.6.0] - 2026-10-14
8
39
 
9
40
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switchback
3
- Version: 0.6.0
3
+ Version: 0.6.2
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
@@ -27,9 +27,9 @@ License-File: LICENSE
27
27
  License-File: NOTICE
28
28
  Requires-Dist: markdownify
29
29
  Requires-Dist: beautifulsoup4
30
- Requires-Dist: pypdf
31
- Requires-Dist: requests
32
- Requires-Dist: curl_cffi
30
+ Requires-Dist: pypdf>=6.19
31
+ Requires-Dist: requests>=2.33
32
+ Requires-Dist: curl_cffi>=0.15
33
33
  Provides-Extra: cloudflare
34
34
  Requires-Dist: cloudscraper; extra == "cloudflare"
35
35
  Provides-Extra: browser
@@ -398,6 +398,7 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
398
398
  - `SCRAPER_PROXY` — route *all* tiers/URLs through a proxy
399
399
  - `SCRAPER_EGRESS_PROXY` — route only walled hosts through a proxy (see [Cost-scoped residential egress](#cost-scoped-residential-egress))
400
400
  - `SEARXNG_URL` — defaults to `http://localhost:8888`
401
+ - `SCRAPER_ARXIV_CONTACT` — optional email added to the User-Agent of arXiv API calls (arXiv asks clients to identify themselves; the default names only the project URL)
401
402
  - `SCRAPER_STATE_DIR` — where the botwall DB/event log + session cache live
402
403
  - `SCRAPER_COOKIES_FILE` — Netscape `cookies.txt` to scrape login-gated hosts (injected into the HTTP and browser tiers)
403
404
  - `SCRAPER_CAPTCHA_PROVIDER` + `SCRAPER_CAPTCHA_API_KEY` — opt-in, off by default: wire a third-party solver (2captcha/capsolver/capmonster/anticaptcha/deathbycaptcha/9kw) into tier_3 for Turnstile/reCAPTCHA/hCaptcha on CF hosts. **Paid**, billed per solve by the provider.
@@ -333,6 +333,7 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
333
333
  - `SCRAPER_PROXY` — route *all* tiers/URLs through a proxy
334
334
  - `SCRAPER_EGRESS_PROXY` — route only walled hosts through a proxy (see [Cost-scoped residential egress](#cost-scoped-residential-egress))
335
335
  - `SEARXNG_URL` — defaults to `http://localhost:8888`
336
+ - `SCRAPER_ARXIV_CONTACT` — optional email added to the User-Agent of arXiv API calls (arXiv asks clients to identify themselves; the default names only the project URL)
336
337
  - `SCRAPER_STATE_DIR` — where the botwall DB/event log + session cache live
337
338
  - `SCRAPER_COOKIES_FILE` — Netscape `cookies.txt` to scrape login-gated hosts (injected into the HTTP and browser tiers)
338
339
  - `SCRAPER_CAPTCHA_PROVIDER` + `SCRAPER_CAPTCHA_API_KEY` — opt-in, off by default: wire a third-party solver (2captcha/capsolver/capmonster/anticaptcha/deathbycaptcha/9kw) into tier_3 for Turnstile/reCAPTCHA/hCaptcha on CF hosts. **Paid**, billed per solve by the provider.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "switchback"
7
- version = "0.6.0"
7
+ version = "0.6.2"
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"
@@ -37,9 +37,9 @@ classifiers = [
37
37
  dependencies = [
38
38
  "markdownify",
39
39
  "beautifulsoup4",
40
- "pypdf",
41
- "requests",
42
- "curl_cffi", # tier1: TLS-impersonating HTTP
40
+ "pypdf>=6.19", # older releases hang or blow up memory on crafted PDFs (DoS CVEs)
41
+ "requests>=2.33", # CVE-2024-35195, CVE-2024-47081, CVE-2026-25645
42
+ "curl_cffi>=0.15", # tier_2: TLS-impersonating HTTP (chrome145/146 targets)
43
43
  ]
44
44
 
45
45
  [project.optional-dependencies]
@@ -55,11 +55,24 @@ _BOTWALL_MARKERS = (
55
55
  ("humans only", "datadome"), # DataDome (e.g. Glassdoor)
56
56
  ("access denied", "akamai"), # Akamai / generic 403
57
57
  ("unusual traffic from your computer", "google"), # Google bot interstitial
58
+ ("you have been blocked", "cloudflare"), # CF WAF block ("Sorry, you have been blocked")
58
59
  ("are you a human", "generic"),
59
60
  ("ddos protection by", "generic"), # generic CDN challenge
60
61
  )
61
62
  _BOTWALL_HEAD_CHARS = 600
62
63
 
64
+ # Inline base64 images: `![](data:image/svg+xml;base64,…)`. Block pages often
65
+ # embed their logo this way, and a single one can run to thousands of chars —
66
+ # enough to push the block phrase out of the head window and to clear the length
67
+ # floor on its own. Base64 never contains ')' or whitespace, so this is exact.
68
+ _INLINE_DATA_URI = re.compile(r"\(data:[^)\s]*\)")
69
+
70
+
71
+ def _strip_inline_data(md: str) -> str:
72
+ """`md` with inline data-URI payloads emptied (`![](data:…)` -> `![]()`), so
73
+ the gates measure and scan text, not image bytes."""
74
+ return _INLINE_DATA_URI.sub("()", md)
75
+
63
76
 
64
77
  def classify_botwall(md: str | None) -> str | None:
65
78
  """Return the vendor of the bot-wall in the head of `md` (cloudflare /
@@ -67,7 +80,7 @@ def classify_botwall(md: str | None) -> str | None:
67
80
  the content doesn't look like a wall. First marker match wins."""
68
81
  if not md:
69
82
  return None
70
- head = md[:_BOTWALL_HEAD_CHARS].lower()
83
+ head = _strip_inline_data(md)[:_BOTWALL_HEAD_CHARS].lower()
71
84
  for marker, vendor in _BOTWALL_MARKERS:
72
85
  if marker in head:
73
86
  return vendor
@@ -189,13 +202,15 @@ def check(url: str, md: str | None) -> str:
189
202
  vendor = classify_botwall(md)
190
203
  if vendor:
191
204
  raise BotWall(f"bot-wall / block page detected ({vendor})", vendor=vendor)
205
+ # Measure text, not inline image bytes; the caller still gets `md` unchanged.
206
+ text = _strip_inline_data(md or "")
192
207
  gate = min_len_for(url)
193
- n = len(md) if md else 0
208
+ n = len(text)
194
209
  if n < gate:
195
210
  raise ShortContent(f"body too short: {n} < {gate}")
196
211
  # Length cleared, but is it actually content? Reject shells/placeholders so a
197
212
  # tier falls through instead of returning a confident false-positive success.
198
- shell = _content_shell_reason(md or "")
213
+ shell = _content_shell_reason(text)
199
214
  if shell:
200
215
  raise ShortContent(f"no article content: {shell}")
201
216
  return md
@@ -64,9 +64,11 @@ def _arxiv(arxiv_id: str, url: str) -> str:
64
64
  # arxiv wants plain requests + an identifying UA (their published guidance);
65
65
  # impersonating Chrome triggers aggressive 429s from their Akamai front-end.
66
66
  import requests
67
+ # The contact is the operator's to give, so it's opt-in; the project URL identifies the client.
68
+ contact = os.getenv("SCRAPER_ARXIV_CONTACT", "").strip()
69
+ ua = f"switchback/1.0 (+https://github.com/akash-kr/switchback{'; mailto:' + contact if contact else ''})"
67
70
  r = requests.get(f"https://export.arxiv.org/api/query?id_list={arxiv_id}",
68
- timeout=_TIMEOUT_S,
69
- headers={"User-Agent": "switchback/1.0 (mailto:akash@theaklabs.com)"})
71
+ timeout=_TIMEOUT_S, headers={"User-Agent": ua})
70
72
  r.raise_for_status()
71
73
  ns = {"atom": "http://www.w3.org/2005/Atom"}
72
74
  entry = ET.fromstring(r.text).find("atom:entry", ns)
@@ -37,7 +37,7 @@ _TIMEOUT_S = float(os.getenv("SCRAPER_TIER_2_TIMEOUT_S", "15"))
37
37
 
38
38
  # Recent Chrome JA3 targets available in curl_cffi 0.15.x. A small spread of real
39
39
  # versions mirrors how live traffic is distributed across Chrome releases.
40
- _IMPERSONATE_TARGETS = ("chrome131", "chrome136", "chrome142")
40
+ _IMPERSONATE_TARGETS = ("chrome142", "chrome145", "chrome146")
41
41
 
42
42
 
43
43
  def _impersonate_for(url: str) -> str:
@@ -13,6 +13,7 @@ before the paid Firecrawl tier.
13
13
  """
14
14
  from __future__ import annotations
15
15
 
16
+ import importlib
16
17
  import logging
17
18
  import os
18
19
 
@@ -43,14 +44,15 @@ def disabled() -> bool:
43
44
 
44
45
 
45
46
  def _geoip_available() -> bool:
46
- """camoufox's geoip matching needs the `geoip2` package (the camoufox[geoip]
47
- extra). Without it, requesting geoip raises and kills the whole tier — so we
48
- probe and degrade gracefully instead."""
49
- try:
50
- import geoip2 # noqa: F401
51
- return True
52
- except Exception:
53
- return False
47
+ """camoufox's geoip matching needs its camoufox[geoip] extra (`geoip2` before
48
+ 0.5, `maxminddb` from 0.5). Without it, requesting geoip raises and kills the
49
+ whole tier — so we ask camoufox itself and degrade gracefully instead."""
50
+ for module in ("camoufox.geolocation", "camoufox.locale"): # 0.5+, then 0.4
51
+ try:
52
+ return bool(importlib.import_module(module).ALLOW_GEOIP)
53
+ except (ImportError, AttributeError):
54
+ continue
55
+ return False
54
56
 
55
57
 
56
58
  def _launch_opts() -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switchback
3
- Version: 0.6.0
3
+ Version: 0.6.2
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
@@ -27,9 +27,9 @@ License-File: LICENSE
27
27
  License-File: NOTICE
28
28
  Requires-Dist: markdownify
29
29
  Requires-Dist: beautifulsoup4
30
- Requires-Dist: pypdf
31
- Requires-Dist: requests
32
- Requires-Dist: curl_cffi
30
+ Requires-Dist: pypdf>=6.19
31
+ Requires-Dist: requests>=2.33
32
+ Requires-Dist: curl_cffi>=0.15
33
33
  Provides-Extra: cloudflare
34
34
  Requires-Dist: cloudscraper; extra == "cloudflare"
35
35
  Provides-Extra: browser
@@ -398,6 +398,7 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
398
398
  - `SCRAPER_PROXY` — route *all* tiers/URLs through a proxy
399
399
  - `SCRAPER_EGRESS_PROXY` — route only walled hosts through a proxy (see [Cost-scoped residential egress](#cost-scoped-residential-egress))
400
400
  - `SEARXNG_URL` — defaults to `http://localhost:8888`
401
+ - `SCRAPER_ARXIV_CONTACT` — optional email added to the User-Agent of arXiv API calls (arXiv asks clients to identify themselves; the default names only the project URL)
401
402
  - `SCRAPER_STATE_DIR` — where the botwall DB/event log + session cache live
402
403
  - `SCRAPER_COOKIES_FILE` — Netscape `cookies.txt` to scrape login-gated hosts (injected into the HTTP and browser tiers)
403
404
  - `SCRAPER_CAPTCHA_PROVIDER` + `SCRAPER_CAPTCHA_API_KEY` — opt-in, off by default: wire a third-party solver (2captcha/capsolver/capmonster/anticaptcha/deathbycaptcha/9kw) into tier_3 for Turnstile/reCAPTCHA/hCaptcha on CF hosts. **Paid**, billed per solve by the provider.
@@ -1,8 +1,8 @@
1
1
  markdownify
2
2
  beautifulsoup4
3
- pypdf
4
- requests
5
- curl_cffi
3
+ pypdf>=6.19
4
+ requests>=2.33
5
+ curl_cffi>=0.15
6
6
 
7
7
  [all]
8
8
  cloudscraper
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes