pryscraper/ultimate_scraper.py
cryptorugmunch 345cd79bc9 feat(pry): production readiness pass — apify actor, async db, retry wiring, tests, observability, mypy
- Pin Dockerfile --workers 1

- Wire retry.py + circuit breakers into ultimate_scraper tiers

- Add Apify actor (apify_actor.py, Dockerfile.apify, .actor/actor.json)

- Add async SQLAlchemy support alongside sync db.py

- Add 31 HTTP integration tests (tests/test_api_integration.py + test_api_mcp.py)

- Add OTLP exporter support in observability.py

- Re-enable mypy var-annotated error code; fix annotations

- Improve CI workflow (pip cache, install -e .[dev], gitleaks, commitlint, 40% coverage gate)
2026-07-03 14:41:41 +02:00

573 lines
23 KiB
Python

"""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 errors import InvalidRequestError
from observability import SCRAPE_LATENCY
from resilience import registry
from retry import RetryConfig, async_retry
from url_guard import validate_url
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
# Per-tier retry configurations for transient failure resilience.
# Browser-based tiers (undetected, camoufox, playwright) use 0 retries
# because launching a browser is expensive; network-only tiers retry 1-2x.
_RETRY_CONFIGS: ClassVar[dict[str, RetryConfig]] = {
"direct": RetryConfig(max_retries=2, base_delay=0.5, max_delay=5.0),
"tls_fingerprint": RetryConfig(max_retries=1, base_delay=1.0, max_delay=5.0),
"cloudscraper": RetryConfig(max_retries=1, base_delay=1.0, max_delay=5.0),
"flaresolverr": RetryConfig(max_retries=2, base_delay=2.0, max_delay=10.0),
"undetected": RetryConfig(max_retries=0),
"camoufox": RetryConfig(max_retries=0),
"playwright": RetryConfig(max_retries=0),
"googlebot": RetryConfig(max_retries=2, base_delay=0.5, max_delay=5.0),
"premium_proxy": RetryConfig(max_retries=2, base_delay=1.0, max_delay=8.0),
"archive": RetryConfig(max_retries=2, base_delay=0.5, max_delay=5.0),
"google_cache": RetryConfig(max_retries=2, base_delay=0.5, max_delay=5.0),
"tor": RetryConfig(max_retries=1, base_delay=2.0, max_delay=10.0),
}
_DEFAULT_RETRY: ClassVar[RetryConfig] = RetryConfig(max_retries=1, base_delay=1.0)
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] = []
try:
validate_url(url)
except InvalidRequestError as e:
return {"status": "error", "url": url, "error": str(e)}
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:
retry_cfg = self._RETRY_CONFIGS.get(tier_name, self._DEFAULT_RETRY)
if tier_name == "premium_proxy":
html = await async_retry(tier_fn, url, proxy_url, opts, config=retry_cfg)
else:
html = await async_retry(
tier_fn, url, opts, proxy_url=proxy_url, config=retry_cfg
)
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 SCRAPE_LATENCY:
SCRAPE_LATENCY.labels(method=tier_name).observe(latency)
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"<body[^>]*>(.*?)</body>", 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