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:
- Stage 0 — Clean. Full page renders. Server takes ~200ms. HTML has expected structure density.
-
Stage 1 — Throttled. Full page still renders but latency increases (server is doing more analysis on this session). Response may include new
Set-Cookieheaders with scoring tokens. -
Stage 2 — Degraded. Page renders but JavaScript challenges are injected as invisible elements.
cf-chlorpx-captchaclasses appear in HTML but no visible prompt yet. - 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),
)
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
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),
}
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)