switchback 0.6.1__tar.gz → 0.6.3__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.1 → switchback-0.6.3}/CHANGELOG.md +21 -0
  2. {switchback-0.6.1 → switchback-0.6.3}/PKG-INFO +1 -1
  3. {switchback-0.6.1 → switchback-0.6.3}/pyproject.toml +1 -1
  4. {switchback-0.6.1 → switchback-0.6.3}/switchback/policy/gates.py +18 -3
  5. {switchback-0.6.1 → switchback-0.6.3}/switchback/tiers/_browser.py +8 -0
  6. {switchback-0.6.1 → switchback-0.6.3}/switchback/tiers/tier_4.py +1 -1
  7. {switchback-0.6.1 → switchback-0.6.3}/switchback/tiers/tier_5.py +1 -1
  8. {switchback-0.6.1 → switchback-0.6.3}/switchback.egg-info/PKG-INFO +1 -1
  9. {switchback-0.6.1 → switchback-0.6.3}/.env.example +0 -0
  10. {switchback-0.6.1 → switchback-0.6.3}/CONTRIBUTING.md +0 -0
  11. {switchback-0.6.1 → switchback-0.6.3}/LICENSE +0 -0
  12. {switchback-0.6.1 → switchback-0.6.3}/MANIFEST.in +0 -0
  13. {switchback-0.6.1 → switchback-0.6.3}/NOTICE +0 -0
  14. {switchback-0.6.1 → switchback-0.6.3}/README.md +0 -0
  15. {switchback-0.6.1 → switchback-0.6.3}/SECURITY.md +0 -0
  16. {switchback-0.6.1 → switchback-0.6.3}/clients/node_bridge.md +0 -0
  17. {switchback-0.6.1 → switchback-0.6.3}/clients/python_client.py +0 -0
  18. {switchback-0.6.1 → switchback-0.6.3}/config/botwall_skip_urls.txt +0 -0
  19. {switchback-0.6.1 → switchback-0.6.3}/config/extraction.example.json +0 -0
  20. {switchback-0.6.1 → switchback-0.6.3}/setup.cfg +0 -0
  21. {switchback-0.6.1 → switchback-0.6.3}/switchback/__init__.py +0 -0
  22. {switchback-0.6.1 → switchback-0.6.3}/switchback/__main__.py +0 -0
  23. {switchback-0.6.1 → switchback-0.6.3}/switchback/api.py +0 -0
  24. {switchback-0.6.1 → switchback-0.6.3}/switchback/concurrency.py +0 -0
  25. {switchback-0.6.1 → switchback-0.6.3}/switchback/content_cache.py +0 -0
  26. {switchback-0.6.1 → switchback-0.6.3}/switchback/doctor.py +0 -0
  27. {switchback-0.6.1 → switchback-0.6.3}/switchback/egress.py +0 -0
  28. {switchback-0.6.1 → switchback-0.6.3}/switchback/extract.py +0 -0
  29. {switchback-0.6.1 → switchback-0.6.3}/switchback/flags.py +0 -0
  30. {switchback-0.6.1 → switchback-0.6.3}/switchback/highlights.py +0 -0
  31. {switchback-0.6.1 → switchback-0.6.3}/switchback/mcp_server.py +0 -0
  32. {switchback-0.6.1 → switchback-0.6.3}/switchback/normalize.py +0 -0
  33. {switchback-0.6.1 → switchback-0.6.3}/switchback/orchestrator.py +0 -0
  34. {switchback-0.6.1 → switchback-0.6.3}/switchback/policy/__init__.py +0 -0
  35. {switchback-0.6.1 → switchback-0.6.3}/switchback/policy/botwall.py +0 -0
  36. {switchback-0.6.1 → switchback-0.6.3}/switchback/py.typed +0 -0
  37. {switchback-0.6.1 → switchback-0.6.3}/switchback/reporting.py +0 -0
  38. {switchback-0.6.1 → switchback-0.6.3}/switchback/search.py +0 -0
  39. {switchback-0.6.1 → switchback-0.6.3}/switchback/server.py +0 -0
  40. {switchback-0.6.1 → switchback-0.6.3}/switchback/session_cache.py +0 -0
  41. {switchback-0.6.1 → switchback-0.6.3}/switchback/session_trace.py +0 -0
  42. {switchback-0.6.1 → switchback-0.6.3}/switchback/state.py +0 -0
  43. {switchback-0.6.1 → switchback-0.6.3}/switchback/tiers/__init__.py +0 -0
  44. {switchback-0.6.1 → switchback-0.6.3}/switchback/tiers/tier_1.py +0 -0
  45. {switchback-0.6.1 → switchback-0.6.3}/switchback/tiers/tier_2.py +0 -0
  46. {switchback-0.6.1 → switchback-0.6.3}/switchback/tiers/tier_3.py +0 -0
  47. {switchback-0.6.1 → switchback-0.6.3}/switchback/tiers/tier_6.py +0 -0
  48. {switchback-0.6.1 → switchback-0.6.3}/switchback/tiers/tier_7.py +0 -0
  49. {switchback-0.6.1 → switchback-0.6.3}/switchback/tracing.py +0 -0
  50. {switchback-0.6.1 → switchback-0.6.3}/switchback.egg-info/SOURCES.txt +0 -0
  51. {switchback-0.6.1 → switchback-0.6.3}/switchback.egg-info/dependency_links.txt +0 -0
  52. {switchback-0.6.1 → switchback-0.6.3}/switchback.egg-info/entry_points.txt +0 -0
  53. {switchback-0.6.1 → switchback-0.6.3}/switchback.egg-info/requires.txt +0 -0
  54. {switchback-0.6.1 → switchback-0.6.3}/switchback.egg-info/top_level.txt +0 -0
@@ -4,6 +4,27 @@ 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.3] - 2026-10-14
8
+
9
+ ### Fixed
10
+ - **"Page not found" pages returned as articles by the browser tiers.** tier_4
11
+ and tier_5 ignored the HTTP status of the page they loaded, so a site answering
12
+ 404 (seen on bhaskarenglish.in, which serves its 404 page to some bot traffic)
13
+ had its "404 - Page not found" page returned as a success. A 404 or 410 now
14
+ fails the tier, as it already did in tier_2, and the cascade moves on.
15
+
16
+ ## [0.6.2] - 2026-10-14
17
+
18
+ ### Fixed
19
+ - **Block pages hidden behind inline images were returned as successes.** A
20
+ bot-wall page that embeds its logo as a base64 `data:` image (e.g. Indeed's
21
+ Cloudflare block page, ~7k chars of SVG before "You have been blocked") pushed
22
+ the block phrase past the 600-char head scan *and* cleared the 2000-char length
23
+ floor on image bytes alone, so a browser tier reported `OK` with the block page
24
+ as content. The quality gates now ignore inline `data:` payloads when scanning
25
+ and measuring (returned markdown is unchanged), and Cloudflare's WAF copy "you
26
+ have been blocked" is a recognised wall (vendor `cloudflare`).
27
+
7
28
  ## [0.6.1] - 2026-10-14
8
29
 
9
30
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switchback
3
- Version: 0.6.1
3
+ Version: 0.6.3
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "switchback"
7
- version = "0.6.1"
7
+ version = "0.6.3"
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"
@@ -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
@@ -34,6 +34,14 @@ def looks_blocked(html: str, url: str) -> bool:
34
34
  return _looks_like_botwall(html_to_markdown(html, base_url=url))
35
35
 
36
36
 
37
+ def raise_if_missing(response) -> None:
38
+ """Fail the tier on a 404/410 document, as tier_2 does; a browser otherwise
39
+ renders the site's "Page not found" page and returns it as the article."""
40
+ status = getattr(response, "status", None)
41
+ if status in (404, 410):
42
+ raise RuntimeError(f"HTTP Error {status}: page not found")
43
+
44
+
37
45
  def response_bytes(responses) -> int:
38
46
  """Total wire bytes a render pulled across every resource — the residential-cost
39
47
  basis. Reads only the Content-Length header: it's non-blocking. (We deliberately
@@ -80,7 +80,7 @@ def fetch(url: str, timeout_ms: int = int(_TIMEOUT_S * 1000)) -> str:
80
80
  page = ctx.new_page()
81
81
  responses: list = []
82
82
  page.on("response", lambda resp: responses.append(resp))
83
- page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded")
83
+ _browser.raise_if_missing(page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded"))
84
84
  html = page.content()
85
85
  if len(html) < 5000:
86
86
  try:
@@ -86,7 +86,7 @@ def fetch(url: str) -> str:
86
86
  auth = session_cache.browser_cookies(url)
87
87
  if auth:
88
88
  page.context.add_cookies(auth)
89
- page.goto(url, wait_until="networkidle", timeout=_TIMEOUT_MS)
89
+ _browser.raise_if_missing(page.goto(url, wait_until="networkidle", timeout=_TIMEOUT_MS))
90
90
  html = page.content()
91
91
  # JS bot-manager sensor interstitial → settle + reload once.
92
92
  if _browser.looks_blocked(html, url):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switchback
3
- Version: 0.6.1
3
+ Version: 0.6.3
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes