"""Pry — Ultimate Anti-Detection Scraper. Multi-strategy scraping with 10+ bypass methods and intelligent fallback. Beats Cloudflare, DataDome, Akamai, Imperva, PerimeterX, and generic blocks.""" # SPDX-License-Identifier: BSL-1.1 # Copyright (c) 2026 Rug Munch Media LLC # # Part of Pry — Stealth / Anti-Detection Module # Licensed under Business Source License 1.1 — see LICENSE-BSL-STEALTH. # Change Date: 2029-01-01 (converts to MIT). import importlib.util import logging import os import random import re import time from typing import Any, ClassVar from urllib.parse import urlparse from resilience import registry logger = logging.getLogger(__name__) # Try importing optional bypass libraries _has_cloudscraper = importlib.util.find_spec("cloudscraper") is not None _has_undetected = importlib.util.find_spec("undetected_chromedriver") is not None _has_playwright = importlib.util.find_spec("playwright") is not None _has_tor = importlib.util.find_spec("aiohttp_socks") is not None if _has_playwright: from playwright.async_api import async_playwright try: from tls_fingerprint import TLSScraper _has_tls = True except ImportError: _has_tls = False try: from camoufox_integration import CamoufoxBrowser _has_camoufox = True except ImportError: _has_camoufox = False try: from stealth_engine import StealthEngine _stealth_engine = StealthEngine() except ImportError: _stealth_engine = None class UltimateScraper: """12-tier anti-detection scraper with automatic fallback. Tiers: 1. Direct HTTP (smart headers, rotating UAs) 2. TLS fingerprint impersonation (curl_cffi JA3/JA4 bypass) 3. cloudscraper (Python-native Cloudflare bypass) 4. FlareSolverr (Cloudflare/WAF bypass service) 5. undetected-chromedriver (modified Chrome, no detection) 6. Camoufox stealth Firefox (source-level anti-fingerprinting) 7. Playwright stealth (full browser with human behavior) 8. Googlebot UA (search engine crawl mimic) 9. Premium proxy retry (after cheap tiers fail) 10. Archive.org / Wayback Machine (cached version) 11. Google Cache (cached version) 12. Tor proxy (anonymous routing via SOCKS5) """ USER_AGENTS: ClassVar[list[str]] = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0", ] def __init__(self) -> None: self._ua_index = 0 self._tls_scraper = TLSScraper() if _has_tls else None self._camoufox = CamoufoxBrowser() if _has_camoufox else None def _rotate_ua(self) -> str: ua = self.USER_AGENTS[self._ua_index % len(self.USER_AGENTS)] self._ua_index += 1 return ua def _build_headers(self, url: str) -> dict[str, str]: parsed = urlparse(url) return { "User-Agent": self._rotate_ua(), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": random.choice( ["en-US,en;q=0.9", "en-GB,en;q=0.8", "en-US,en;q=0.7"] ), "Referer": f"https://{parsed.netloc}/", "DNT": "1", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", } async def scrape(self, url: str, options: dict[str, Any] | None = None) -> dict[str, Any]: """Scrape any URL with automatic bypass strategy selection. Will try up to 10 strategies until one succeeds.""" opts = options or {} errors: list[str] = [] proxy_url = self._resolve_proxy_url(opts) # Tier 0: Premium proxy (if configured and forced or first attempt) if proxy_url and opts.get("use_premium_proxy_first", True): html = await self._tier_premium_proxy(url, proxy_url, opts) if html and self._is_valid(html): return self._result(url, html, "premium_proxy") errors.append("premium_proxy_failed") # Build tier list. Order starts cheap/direct and escalates. tier_order = [ ("direct", self._tier_direct), ("tls_fingerprint", self._tier_tls_fingerprint), ("cloudscraper", self._tier_cloudscraper), ("flaresolverr", self._tier_flaresolverr), ("undetected", self._tier_undetected), ("camoufox", self._tier_camoufox), ("playwright", self._tier_playwright), ("googlebot", self._tier_googlebot), ] # Premium proxy retry (after cheap tiers fail) if not tried first. if proxy_url and not opts.get("use_premium_proxy_first", True): tier_order.append(("premium_proxy", self._tier_premium_proxy)) tier_order.extend([ ("archive", self._tier_archive), ("google_cache", self._tier_google_cache), ("tor", self._tier_tor), ]) # If domain memory knows a good tier, try it first. parsed = urlparse(url) domain = parsed.netloc best = registry.best_tier_for_domain(domain, [name for name, _ in tier_order]) if best: tier_order.sort(key=lambda item: (item[0] != best, item[0])) # External service tiers protected by circuit breakers. breaker_for_tier = { "flaresolverr": "flaresolverr", "playwright": "playwright", "camoufox": "camoufox", "tls_fingerprint": "tls_fingerprint", } for tier_name, tier_fn in tier_order: # Skip external tiers whose circuit breaker is open. breaker_name = breaker_for_tier.get(tier_name) if breaker_name and not registry.is_service_allowed(breaker_name): logger.debug("tier_skipped_circuit_open", extra={"tier": tier_name, "domain": domain}) errors.append(f"{tier_name}: circuit_open") continue start = time.time() try: if tier_name == "premium_proxy": html = await tier_fn(url, proxy_url, opts) else: html = await tier_fn(url, opts, proxy_url=proxy_url) except Exception as e: # noqa: BLE001 logger.debug("tier_exception", extra={"tier": tier_name, "error": str(e)[:50]}) html = None latency = time.time() - start success = bool(html and self._is_valid(html)) registry.record_domain_tier_result(domain, tier_name, success, latency) if breaker_name: registry.record_service_result(breaker_name, success) if success: method = "premium_proxy" if tier_name == "premium_proxy" else tier_name return self._result(url, html, method) errors.append(f"{tier_name}_failed") last_err = errors[-1] if errors else "all_tiers_failed" return { "status": "error", "url": url, "error": "; ".join(errors[-5:]) if errors else last_err, "content": "", "proxy_recommendation": self._recommend_proxy(last_err), } def _resolve_proxy_url(self, opts: dict[str, Any]) -> str | None: """Resolve the configured proxy URL from options, ProxyManager, or env.""" if opts.get("proxy_url"): return opts["proxy_url"] if opts.get("proxy") is False: return None try: from proxy_manager import ProxyManager pm = ProxyManager() return pm.get_proxy_url() except Exception as e: # noqa: BLE001 logger.debug("proxy_resolve_failed", extra={"error": str(e)[:80]}) env_url = os.getenv("PRY_PROXY_URL") or os.getenv("PROXY_URL") return env_url or None def _recommend_proxy(self, last_error: str) -> dict[str, Any] | None: """Build a proxy recommendation for the response when scraping fails.""" try: from proxy_manager import ProxyManager return ProxyManager().get_recommendation(last_error) except Exception as e: # noqa: BLE001 logger.debug("proxy_recommend_failed", extra={"error": str(e)[:80]}) return None def _result(self, url: str, html: str, method: str) -> dict[str, Any]: return { "status": "ok", "url": url, "raw_html": html, "method": method, "content": self._extract_text(html), } def _is_valid(self, html: str | None) -> bool: if not html or len(html) < 500: return False lower = html.lower() block_indicators = [ "cloudflare", "attention required", "just a moment", "enable javascript", "verify you are human", ] if any(i in lower for i in block_indicators) and len(html) < 5000: return False body = re.search(r"]*>(.*?)", html, re.DOTALL) if body: text = re.sub(r"<[^>]+>", " ", body.group(1)).strip() if len(text) < 50: return False return True def _extract_text(self, html: str) -> str: """Extract readable text from HTML.""" try: from trafilatura import extract content = extract( html, output_format="markdown", include_links=True, include_images=False ) if content and len(content) > 100: return content except Exception: # noqa: BLE001 pass try: from readability import Document doc = Document(html) content = doc.summary() if content and len(content) > 100: from markdownify import markdownify return markdownify(content, heading_arrows=False, strip=["script", "style"]) except Exception: # noqa: BLE001 pass # Last resort: strip HTML tags text = re.sub(r"<[^>]+>", " ", html) text = re.sub(r"\s+", " ", text).strip() return text[:50000] if len(text) > 50000 else text async def _tier_direct( self, url: str, opts: dict[str, Any], proxy_url: str | None = None ) -> str | None: try: import httpx async with httpx.AsyncClient(timeout=30, follow_redirects=True, proxy=proxy_url) as c: r = await c.get(url, headers=self._build_headers(url)) if r.is_success: return r.text except (httpx.HTTPError, httpx.RequestError) as e: logger.debug("direct_failed", extra={"error": str(e)[:50]}) return None async def _tier_premium_proxy( self, url: str, proxy_url: str, opts: dict[str, Any] ) -> str | None: """Fetch via the configured premium residential/datacenter proxy.""" try: import httpx async with httpx.AsyncClient(timeout=45, follow_redirects=True, proxy=proxy_url) as c: r = await c.get(url, headers=self._build_headers(url)) if r.is_success: return r.text except (httpx.HTTPError, httpx.RequestError) as e: logger.debug("premium_proxy_failed", extra={"error": str(e)[:50]}) return None async def _tier_cloudscraper(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None: if not _has_cloudscraper: return None try: import cloudscraper scraper = cloudscraper.create_scraper( browser={"browser": "chrome", "platform": "windows", "mobile": False} ) resp = scraper.get(url, timeout=30, headers={"User-Agent": self._rotate_ua()}) if resp.status_code == 200: return resp.text except Exception as e: # noqa: BLE001 logger.debug("cloudscraper_failed", extra={"error": str(e)[:50]}) return None async def _tier_flaresolverr(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None: from settings import settings if not settings.flaresolverr_url: return None try: import httpx payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000} async with httpx.AsyncClient(timeout=60) as c: r = await c.post(settings.flaresolverr_url, json=payload) if r.is_success: data = r.json() solution = data.get("solution", {}) if solution.get("status") == 200: return solution["response"] except (httpx.HTTPError, httpx.RequestError) as e: logger.debug("flaresolverr_failed", extra={"error": str(e)[:50]}) return None async def _tier_undetected(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None: if not _has_undetected: return None try: import asyncio import time def _do_fetch() -> str: import undetected_chromedriver as uc driver = uc.Chrome(headless=True, use_subprocess=True) driver.get(url) # Note: time.sleep is used inside a sync thread-pool task because # undetected_chromedriver's blocking API runs in asyncio.to_thread. # Replacing with asyncio.sleep would require restructuring the tier # to be fully async; this sync sleep is bounded and does not block # the event loop directly. time.sleep(random.uniform(2, 4)) html = driver.page_source driver.quit() return html return await asyncio.to_thread(_do_fetch) except Exception as e: # noqa: BLE001 logger.debug("undetected_failed", extra={"error": str(e)[:50]}) return None async def _tier_playwright(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None: if not _has_playwright: return None try: async with async_playwright() as pw: browser = await pw.chromium.launch( headless=True, args=[ "--disable-blink-features=AutomationControlled", "--no-sandbox", "--disable-dev-shm-usage", "--disable-web-security", ], ) context = await browser.new_context( viewport={ "width": random.choice([1920, 1366, 1440]), "height": random.choice([1080, 768, 900]), }, user_agent=self._rotate_ua(), locale="en-US", timezone_id="America/New_York", ) stealth_script = _stealth_engine.generate_all() if _stealth_engine else "" if stealth_script: await context.add_init_script(stealth_script) page = await context.new_page() await page.goto(url, wait_until="domcontentloaded", timeout=60000) await page.wait_for_timeout(random.randint(1000, 3000)) for _ in range(random.randint(2, 5)): await page.evaluate(f"window.scrollBy(0, {random.randint(200, 500)})") await page.wait_for_timeout(random.randint(300, 1000)) await page.mouse.move(random.randint(100, 1400), random.randint(100, 800)) html = await page.content() await browser.close() return html except OSError as e: logger.debug("playwright_failed", extra={"error": str(e)[:50]}) return None async def _tier_tls_fingerprint( self, url: str, opts: dict[str, Any], proxy_url: str | None = None ) -> str | None: """Fetch using curl_cffi TLS fingerprint impersonation.""" if not self._tls_scraper or not self._tls_scraper.is_available(): return None try: result = await self._tls_scraper.fetch( url, headers=self._build_headers(url), proxy=proxy_url or "", timeout=opts.get("timeout", 30), ) if result.get("success") and result.get("status_code", 0) < 400: return result.get("text", "") except Exception as e: # noqa: BLE001 logger.debug("tls_fingerprint_failed", extra={"error": str(e)[:50]}) return None async def _tier_camoufox( self, url: str, opts: dict[str, Any], proxy_url: str | None = None ) -> str | None: """Fetch using Camoufox anti-detection Firefox.""" if not self._camoufox or not self._camoufox.is_available(): return None try: result = await self._camoufox.fetch( url, wait_time=opts.get("wait_time", 3000), proxy=proxy_url or "", ) if result.get("success"): return result.get("content", "") except Exception as e: # noqa: BLE001 logger.debug("camoufox_failed", extra={"error": str(e)[:50]}) return None async def _tier_googlebot( self, url: str, opts: dict[str, Any], proxy_url: str | None = None ) -> str | None: try: import httpx headers = self._build_headers(url) headers["User-Agent"] = ( "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" ) async with httpx.AsyncClient(timeout=30, follow_redirects=True, proxy=proxy_url) as c: r = await c.get(url, headers=headers) if r.is_success: return r.text except (httpx.HTTPError, httpx.RequestError) as e: logger.debug("googlebot_failed", extra={"error": str(e)[:50]}) return None async def _tier_archive(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None: """Fetch from Wayback Machine CDX API for latest snapshot.""" try: import httpx cdx_url = f"https://web.archive.org/cdx/search/cdx?url={url}&output=json&limit=1&fl=timestamp,original" async with httpx.AsyncClient(timeout=15) as c: r = await c.get(cdx_url, headers={"User-Agent": "Mozilla/5.0"}) if r.is_success: data = r.json() if len(data) > 1: timestamp = data[1][0] archive_url = f"https://web.archive.org/web/{timestamp}id_/{url}" ar = await c.get(archive_url, timeout=30, follow_redirects=True) if ar.is_success: return ar.text except (httpx.HTTPError, httpx.RequestError) as e: logger.debug("archive_failed", extra={"error": str(e)[:50]}) return None async def _tier_google_cache(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None: try: import httpx cache_url = f"https://webcache.googleusercontent.com/search?q=cache:{url}" async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c: r = await c.get(cache_url, headers={"User-Agent": "Mozilla/5.0"}) if r.is_success: return r.text except (httpx.HTTPError, httpx.RequestError) as e: logger.debug("google_cache_failed", extra={"error": str(e)[:50]}) return None async def _tier_tor(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None: """Fetch via Tor SOCKS5 proxy.""" if not _has_tor: return None try: import aiohttp from aiohttp_socks import ProxyConnector connector = ProxyConnector.from_url("socks5://127.0.0.1:9050") async with ( aiohttp.ClientSession(connector=connector) as session, session.get( url, headers=self._build_headers(url), timeout=aiohttp.ClientTimeout(total=60), ) as resp, ): if resp.status == 200: return await resp.text() except aiohttp.ClientError as e: logger.debug("tor_failed", extra={"error": str(e)[:50]}) return None