pryscraper/tls_fingerprint.py
cryptorugmunch a7c30b12cd
Some checks failed
CI / lint (push) Failing after 2s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 2s
CI / Secret scan (gitleaks) (push) Failing after 1s
CI / Security audit (bandit) (push) Failing after 2s
chore(lint): auto-fix 253 of 283 ruff issues (F401, I001, E402, RUF100, UP037, SIM105)
Mass ruff auto-fix:
  - ruff check --fix: 109 issues fixed (F401 unused imports,
    I001 unsorted imports, UP037 quoted annotations, SIM105
    suppressible exception, RUF100 unused-noqa)
  - ruff check --fix --unsafe-fixes: 22 additional issues
  - ruff format: 70 files reformatted
  - Manual pass: fix 16 misplaced import httpx lines
  - Manual pass: fix remaining E402 (import-after-docstring)

Result: 283 errors -> 30 errors.

The remaining 30 are real issues that need manual review:
  5 F401 unused-import (likely auto-generated stubs)
  5 F821 undefined-name (real bugs in code that references
    redis/pydantic/LLMRegistry without imports)
  3 BLE001 (the compliance LLM fallback is intentional; the
    other two are real)
  3 RUF012 mutable-class-default
  3 SIM105, 3 SIM117, 2 E722, 2 E741
  1 B007, 1 B025, 1 E402, 1 RUF200 (pyproject.toml issue)

Tests: 436/437 pass (1 pre-existing SSE sandbox failure).
format check + import sort: now clean.
make ci: still gated on the 30 remaining real issues.
Follow-up: triage the 30 issues file-by-file.
2026-07-02 21:51:25 +02:00

190 lines
5.9 KiB
Python

"""Pry — TLS Fingerprint Impersonation using curl_cffi.
This is the SINGLE BIGGEST BOTTLE-NECK in modern scraping. Sites like Cloudflare,
Akamai, DataDome, and PerimeterX use TLS fingerprinting (JA3/JA4) to detect bots.
curl_cffi can impersonate real browser TLS handshakes, bypassing these checks."""
# 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 asyncio
import logging
import time
from typing import Any
logger = logging.getLogger(__name__)
# Check if curl_cffi is available
try:
from curl_cffi.requests import AsyncSession
_has_curl_cffi = True
except ImportError:
_has_curl_cffi = False
# Browser fingerprints that curl_cffi can impersonate
BROWSER_FINGERPRINTS = [
"chrome",
"chrome99",
"chrome100",
"chrome101",
"chrome104",
"chrome107",
"chrome110",
"chrome116",
"chrome119",
"chrome120",
"chrome123",
"chrome124",
"chrome131",
"chrome133",
"chrome99_android",
"chrome131_android",
"edge",
"edge99",
"edge101",
"edge122",
"edge127",
"firefox",
"firefox109",
"firefox117",
"firefox120",
"firefox123",
"firefox124",
"firefox132",
"safari",
"safari15_3",
"safari15_5",
"safari16_5",
"safari17_0",
"safari17_2_ios",
"tor",
]
class TLSScraper:
"""Scraper that uses curl_cffi to impersonate real browser TLS fingerprints.
Bypasses JA3/JA4 fingerprinting that blocks 80%+ of bot traffic."""
def __init__(self, default_impersonate: str = "chrome131"):
self.default_impersonate = (
default_impersonate if default_impersonate in BROWSER_FINGERPRINTS else "chrome"
)
if not _has_curl_cffi:
logger.warning(
"curl_cffi_not_installed",
extra={"hint": "pip install curl_cffi"},
)
async def fetch(
self,
url: str,
impersonate: str = "",
headers: dict[str, str] | None = None,
proxy: str = "",
timeout: int = 30,
cookies: dict[str, str] | None = None,
follow_redirects: bool = True,
) -> dict[str, Any]:
"""Fetch a URL with TLS fingerprint impersonation.
Args:
url: The URL to fetch
impersonate: Browser fingerprint to impersonate (e.g., "chrome131", "firefox132", "safari17_0")
headers: Additional HTTP headers
proxy: Proxy URL (e.g., "http://user:pass@proxy.example.com:8080")
timeout: Request timeout in seconds
cookies: Cookies to send with the request
follow_redirects: Whether to follow HTTP redirects
Returns: {status_code, text, headers, cookies, url, elapsed}
"""
if not _has_curl_cffi:
return {
"success": False,
"error": "curl_cffi not installed. Run: pip install curl_cffi",
}
impersonate = impersonate or self.default_impersonate
if impersonate not in BROWSER_FINGERPRINTS:
impersonate = "chrome"
default_headers = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
if headers:
default_headers.update(headers)
try:
start = time.time()
async with AsyncSession(
impersonate=impersonate,
headers=default_headers,
cookies=cookies or {},
proxy=proxy or None,
timeout=timeout,
follow_redirects=follow_redirects,
) as session:
resp = await session.get(url)
elapsed = time.time() - start
return {
"success": True,
"status_code": resp.status_code,
"text": resp.text,
"content": resp.content,
"headers": dict(resp.headers),
"cookies": dict(resp.cookies),
"url": str(resp.url),
"elapsed": round(elapsed, 2),
"impersonate": impersonate,
}
except Exception as e: # noqa: BLE001
return {
"success": False,
"error": str(e)[:300],
"impersonate": impersonate,
}
async def fetch_with_rotation(
self,
url: str,
fingerprints: list[str] | None = None,
headers: dict[str, str] | None = None,
proxy: str = "",
timeout: int = 30,
) -> dict[str, Any]:
"""Try multiple browser fingerprints until one succeeds (anti-fingerprint rotation)."""
fingerprints = fingerprints or BROWSER_FINGERPRINTS[:10]
for fp in fingerprints:
result = await self.fetch(
url, impersonate=fp, headers=headers, proxy=proxy, timeout=timeout
)
if result.get("success") and result.get("status_code", 0) < 400:
result["matched_fingerprint"] = fp
return result
return {
"success": False,
"error": "All fingerprints failed",
"attempts": len(fingerprints),
}
def is_available(self) -> bool:
return _has_curl_cffi
async def _test_fetch() -> None:
"""Smoke test: verifies the scraper can be instantiated and is_available works."""
s = TLSScraper()
logger.info("tls_scraper_available", extra={"available": s.is_available()})
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(_test_fetch())