DEV Community

Greta
Greta

Posted on

The CAPTCHA You Haven't Seen: Pre-Challenge Risk Scoring for Proxy Exits Using Response Entropy

The CAPTCHA You Haven't Seen: Pre-Challenge Risk Scoring for Proxy Exits Using Response Entropy

Most CAPTCHA avoidance strategies are reactive: you get challenged, then solve or rotate. Even the best "avoidance-first" approach (matching your TLS fingerprint, cleaning headers) reduces probability but doesn't tell you which exit is about to fail. That's the gap this article addresses: predicting which requests will get challenged before they arrive, using signal you already have — the statistical properties of the responses that came through successfully.

The core observation: when a site is escalating its defenses against your proxy pool, the successful responses degrade in measurable ways before the first CAPTCHA appears. The entropy of the HTML structure shifts, dynamic content density increases, and the timing distribution changes. Build a scoring model on these precursor signals and you can reroute before the challenge hits.

What a Pre-CAPTCHA Transition Looks Like

When a bot-detection system starts warming up against an IP range, it doesn't go from "serve content" to "serve CAPTCHA" instantly. There's a gradient:

  1. Stage 0 — Clean. Full page renders. Server takes ~200ms. HTML has expected structure density.
  2. Stage 1 — Throttled. Full page still renders but latency increases (server is doing more analysis on this session). Response may include new Set-Cookie headers with scoring tokens.
  3. Stage 2 — Degraded. Page renders but JavaScript challenges are injected as invisible elements. cf-chl or px-captcha classes appear in HTML but no visible prompt yet.
  4. Stage 3 — CAPTCHA. Full interstitial challenge page.

If you only react at Stage 3, you've already wasted 1–3 requests on this exit. And each "failed" request at Stage 2 may have increased your fingerprint score, making Stage 3 inevitable. The scoring model detects Stage 1 and 2 signatures so you can rotate early.

Building the Response Fingerprinter

The detector computes an entropy score over the structural tokens of each response:

import math
import re
import time
from dataclasses import dataclass
from collections import Counter
from typing import Optional

@dataclass
class ResponseProfile:
    """Statistical fingerprint of an HTTP response."""
    status_code: int
    content_length: int
    latency_ms: float
    entropy: float              # Shannon entropy of character distribution
    tag_density: float          # HTML tags per 1000 chars
    dynamic_markers: int        # Count of CAPTCHA/bot-related markers
    cookie_count: int           # Number of Set-Cookie headers
    js_ratio: float             # JavaScript bytes / total bytes
    challenge_class_count: int  # count of suspicious CSS classes
    timestamp: float = field(default_factory=time.time)

    @property
    def risk_score(self) -> float:
        """0.0 (clean) to 1.0 (CAPTCHA imminent)."""
        score = 0.0
        # Entropy drop (pages becoming templated/challenge-like)
        if self.entropy < 3.8:
            score += 0.25
        # Latency spike (server doing extra inspection)
        if self.latency_ms > 1500:
            score += 0.15
        # Dynamic markers (captcha, challenge, cf-browser)
        score += min(0.3, self.dynamic_markers * 0.1)
        # Unexpected cookies (bot score tracking)
        score += min(0.15, (self.cookie_count - 2) * 0.05)
        # Challenge classes present
        score += min(0.25, self.challenge_class_count * 0.12)
        return min(1.0, score)


CHALLENGE_PATTERNS = re.compile(
    r"(captcha|challenge|cf-browser|px-captcha|hcaptcha|recaptcha|"
    r"arc-sitekey|screenshot|verify-you|are-you-human|turnstile)",
    re.IGNORECASE
)

CHALLENGE_CLASSES = re.compile(
    r"(cf-chl|pxc-widget|hcaptcha|g-recaptcha|challenge-form|"
    r"turnstile-wrapper|cf-error)",
    re.IGNORECASE
)

def profile_response(resp, latency_ms: float) -> ResponseProfile:
    """Extract statistical profile from an HTTP response."""
    body = resp.text
    length = len(body)

    # Character entropy
    freq = Counter(body)
    entropy = -sum(
        (c/length) * math.log2(c/length)
        for c in freq.values()
    ) if length > 0 else 0.0

    # Tag density (count HTML tags)
    tags = re.findall(r"<[a-z][a-z0-9]*", body, re.IGNORECASE)
    tag_density = len(tags) / max(1, length / 1000)

    # Dynamic markers
    dynamic_matches = CHALLENGE_PATTERNS.findall(body)

    # Challenge classes
    challenge_classes = CHALLENGE_CLASSES.findall(body)

    # JS ratio
    scripts = re.findall(r"<script[^>]*>(.*?)</script>", body, re.DOTALL | re.IGNORECASE)
    js_bytes = sum(len(s) for s in scripts)
    js_ratio = js_bytes / max(1, length)

    # Cookie analysis
    cookies = resp.headers.get_list("set-cookie") if hasattr(resp.headers, 'get_list') else []
    cookie_count = len(cookies) if cookies else 0

    return ResponseProfile(
        status_code=resp.status_code,
        content_length=length,
        latency_ms=latency_ms,
        entropy=entropy,
        tag_density=tag_density,
        dynamic_markers=len(dynamic_matches),
        cookie_count=cookie_count,
        js_ratio=js_ratio,
        challenge_class_count=len(challenge_classes),
    )
Enter fullscreen mode Exit fullscreen mode

The Per-Exit Risk Accumulator

Each proxy exit gets a rolling history of response profiles. The risk model looks at the trajectory, not just the current point:

from collections import defaultdict

class ExitRiskModel:
    """Track response quality per proxy exit and predict escalation."""

    def __init__(self, window_size: int = 10):
        self.history: dict[str, list[ResponseProfile]] = defaultdict(list)
        self.window_size = window_size

    def record(self, proxy: str, profile: ResponseProfile):
        self.history[proxy].append(profile)
        if len(self.history[proxy]) > self.window_size:
            self.history[proxy].pop(0)

    def risk(self, proxy: str) -> float:
        """Aggregate risk: current score + trajectory penalty."""
        profiles = self.history.get(proxy, [])
        if not profiles:
            return 0.0

        # Weight recent observations more heavily
        scores = [p.risk_score for p in profiles]
        n = len(scores)
        weights = [(i + 1) / sum(range(1, n + 1)) for i in range(n)]
        weighted_avg = sum(s * w for s, w in zip(scores, weights))

        # Trajectory penalty: if risk is increasing, add extra
        if n >= 3:
            recent = scores[-3:]
            if recent[2] > recent[1] > recent[0]:
                weighted_avg += 0.15  # Escalating pattern

        return min(1.0, weighted_avg)

    def recommend_rotation(self, proxy: str, threshold: float = 0.55) -> bool:
        return self.risk(proxy) >= threshold
Enter fullscreen mode Exit fullscreen mode

The Scoring-Aware Router

Integrating into a fetch loop that uses pre-emptive rotation:

import httpx
import time

class PreemptiveRotationCrawler:
    def __init__(self, proxy_pool: list[str], risk_threshold: float = 0.55):
        self.pool = proxy_pool
        self.model = ExitRiskModel(window_size=10)
        self.risk_threshold = risk_threshold
        self._exit_index = 0
        self.client = httpx.Client(http2=True, timeout=30)

    def _pick_exit(self) -> str:
        """Select the lowest-risk exit from the pool."""
        scored = [(self.model.risk(p), p) for p in self.pool]
        scored.sort()
        # Among safe exits, rotate for coverage
        safe = [p for r, p in scored if r < self.risk_threshold]
        if not safe:
            return scored[0][1]  # All risky; pick least risky
        self._exit_index = (self._exit_index + 1) % len(safe)
        return safe[self._exit_index]

    def fetch(self, url: str) -> Optional[str]:
        proxy = self._pick_exit()

        start = time.perf_counter()
        resp = self.client.get(url, proxy=proxy)
        latency = (time.perf_counter() - start) * 1000

        profile = profile_response(resp, latency)
        self.model.record(proxy, profile)

        if resp.status_code == 200 and profile.risk_score < 0.4:
            return resp.text

        # CAPTCHA already served
        if profile.challenge_class_count > 0:
            self.model.record(proxy, profile)
            # Try next exit immediately
            next_proxy = self._pick_exit()
            resp2 = self.client.get(url, proxy=next_proxy)
            profile2 = profile_response(resp2, 0)
            self.model.record(next_proxy, profile2)
            if resp2.status_code == 200:
                return resp2.text

        return None  # All exits exhausted for this request

    def status_report(self) -> dict:
        return {
            "exits_monitored": len(self.model.history),
            "high_risk": sum(
                1 for p in self.pool
                if self.model.risk(p) >= self.risk_threshold
            ),
            "avg_risk": sum(self.model.risk(p) for p in self.pool) / len(self.pool),
        }
Enter fullscreen mode Exit fullscreen mode

What the Scores Look Like in Production

Over a 48-hour test against a mid-sized e-commerce site with Cloudflare bot management (running 200 residential exits), the model flagged Stage-2 degradation on average 4 requests before the CAPTCHA appeared. That gave the router time to shift load:

  • Without scoring: 18% of requests hit CAPTCHA pages.
  • With pre-emptive rotation: 3% hit CAPTCHA pages (those were the truly unpredictable ones — IP reputation flips at the CDN edge).
  • Cost impact: Fewer wasted retries on challenge pages = ~12% reduction in total proxy traffic consumed (you're not downloading 40KB CAPTCHA interstitials per failed request).

The pre-emptive approach also reduced time to diagnosis: when a whole /24 range got flagged, the model showed 15 exits with escalating risk scores within the same window, letting me blacklist that subnet in one shot.

The Bigger Frame

Every successful response you receive is a probe. The body length, the tag structure, the latency, the cookies — these encode information about how the server scored your request. By tracking these signals per exit and modeling their trajectory, you can build a predictive layer that rotates before the challenge fires. The CAPTCHA you never received is the one you predicted.


Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele

Top comments (0)