Un moniteur de tarifs échoue rarement sur le parsing HTML : il échoue sur un défi CAPTCHA affiché à la place de la grille de prix, après quelques heures de relevés réguliers. La parade tient en deux réglages : espacer les passages pour rester sous le seuil de déclenchement, et résoudre le défi dans la session en cours plutôt que relancer un scraper aveugle. Cet article assemble les deux avec l'API CaptchaAI, du script Python à la cadence cron et au budget en threads.
Périmètre : relevés espacés sur des pages de tarifs publiques, pour votre veille commerciale ou vos tests de QA. Vérifiez les conditions d'utilisation du site visé et, côté RGPD, ne collectez aucune donnée personnelle.
Pourquoi un moniteur de tarifs s'éteint au bout de quelques heures
Les sites de réservation ne bloquent pas la veille tarifaire par principe : ils protègent des moteurs de disponibilité coûteux en calcul. Trois signaux déclenchent presque toujours le défi : une cadence régulière à la seconde près, une session sans cookie de navigation préalable, et une IP datacenter qui interroge dix itinéraires en trente secondes.
Un moniteur ne doit donc pas seulement résoudre un CAPTCHA : il doit le résoudre dans la même session, puis réutiliser cette session pour les relevés suivants. Un token injecté dans une session neuve à chaque passage double la charge et fait recroiser le défi plus vite.
Quels défis CAPTCHA attendre selon le type de site
| Catégorie de site | Type de CAPTCHA | Difficulté |
|---|---|---|
| Compagnies aériennes (sites directs) | reCAPTCHA v3, Cloudflare | Moyenne |
| OTA (Expedia, Booking) | reCAPTCHA v2, Cloudflare Turnstile | Moyenne à élevée |
| Méta-moteurs (Google Flights, Kayak) | reCAPTCHA v3 | Moyenne |
| Compagnies low cost | CAPTCHA image, reCAPTCHA | Faible à moyenne |
| Agrégateurs hôteliers | Cloudflare Challenge | Élevée |
Ces familles sont prises en charge par CaptchaAI : reCAPTCHA v3 se résout en moins de 4 s, Cloudflare Turnstile en moins de 10 s et Cloudflare Challenge en moins de 15 s, avec un taux de réussite élevé. hCaptcha et FunCaptcha (Arkose Labs), en revanche, ne sont pas encore pris en charge : prévoyez une source alternative pour ces axes.
Construire le moniteur de tarifs en Python
Le script ci-dessous récupère la page, détecte le type de défi dans le HTML (data-sitekey pour reCAPTCHA, cf-turnstile pour Turnstile), envoie la tâche à in.php puis interroge res.php jusqu'au token. L'historique reste en mémoire : de quoi calculer les baisses de prix sans base de données.
import requests
import time
import re
import json
import os
from datetime import datetime, timedelta
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
def solve_captcha(params):
params["key"] = API_KEY
resp = requests.get("https://ocr.captchaai.com/in.php", params=params)
if not resp.text.startswith("OK|"):
raise Exception(f"Submit: {resp.text}")
task_id = resp.text.split("|")[1]
for _ in range(60):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY, "action": "get", "id": task_id,
})
if result.text == "CAPCHA_NOT_READY":
continue
if result.text.startswith("OK|"):
return result.text.split("|", 1)[1]
raise Exception(f"Solve: {result.text}")
raise TimeoutError()
class FareMonitor:
def __init__(self):
self.session = requests.Session()
self.session.headers["User-Agent"] = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0.0.0"
)
self.history = []
def fetch_with_captcha(self, url):
"""Fetch a travel page, solving CAPTCHAs if encountered."""
resp = self.session.get(url)
# reCAPTCHA v2/v3
match = re.search(
r'data-sitekey=["\']([A-Za-z0-9_-]+)["\']', resp.text
)
if match:
site_key = match.group(1)
# Detect v3 vs v2
if "recaptcha/api.js?render=" in resp.text:
token = solve_captcha({
"method": "userrecaptcha",
"googlekey": site_key,
"pageurl": url,
"version": "v3",
"action": "search",
})
else:
token = solve_captcha({
"method": "userrecaptcha",
"googlekey": site_key,
"pageurl": url,
})
resp = self.session.post(url, data={
"g-recaptcha-response": token,
})
# Cloudflare Turnstile
if "cf-turnstile" in resp.text:
match = re.search(
r'data-sitekey=["\']([^"\']+)', resp.text
)
if match:
token = solve_captcha({
"method": "turnstile",
"sitekey": match.group(1),
"pageurl": url,
})
resp = self.session.post(url, data={
"cf-turnstile-response": token,
})
return resp.text
def check_fares(self, routes):
"""Check fares for a list of routes."""
results = []
for route in routes:
try:
html = self.fetch_with_captcha(route["url"])
prices = self._extract_prices(html)
result = {
"route": f"{route['origin']}-{route['destination']}",
"date": route["date"],
"prices": prices,
"min_price": min(prices) if prices else None,
"timestamp": datetime.utcnow().isoformat(),
}
results.append(result)
self.history.append(result)
if prices:
print(f" {result['route']} ({route['date']}): "
f"${min(prices)}-${max(prices)}")
else:
print(f" {result['route']}: No prices found")
time.sleep(3) # Respectful delay
except Exception as e:
print(f" {route.get('origin', '?')}-"
f"{route.get('destination', '?')}: ERROR - {e}")
return results
def _extract_prices(self, html):
"""Extract prices from travel page HTML."""
prices = []
# Common price patterns
for match in re.finditer(
r'\$\s*([\d,]+(?:\.\d{2})?)', html
):
price = float(match.group(1).replace(",", ""))
if 20 < price < 10000: # Filter noise
prices.append(price)
return sorted(set(prices))
def detect_price_drops(self, threshold_pct=5):
"""Detect significant price drops in history."""
route_prices = {}
for entry in self.history:
key = f"{entry['route']}_{entry['date']}"
if key not in route_prices:
route_prices[key] = []
if entry["min_price"]:
route_prices[key].append(entry["min_price"])
alerts = []
for key, prices in route_prices.items():
if len(prices) >= 2:
prev = prices[-2]
current = prices[-1]
change_pct = ((current - prev) / prev) * 100
if change_pct < -threshold_pct:
alerts.append({
"route": key,
"previous": prev,
"current": current,
"change": f"{change_pct:.1f}%",
})
return alerts
def export_report(self, filename="fare_report.json"):
"""Export fare history to JSON."""
with open(filename, "w") as f:
json.dump(self.history, f, indent=2)
print(f"Exported {len(self.history)} fare checks to {filename}")
# Define routes to monitor
routes = [
{
"origin": "JFK",
"destination": "LAX",
"date": "2025-03-15",
"url": "https://example-airline.com/flights?from=JFK&to=LAX&date=2025-03-15",
},
{
"origin": "SFO",
"destination": "ORD",
"date": "2025-03-20",
"url": "https://example-airline.com/flights?from=SFO&to=ORD&date=2025-03-20",
},
]
monitor = FareMonitor()
results = monitor.check_fares(routes)
monitor.export_report()
Planifier les relevés sans matraquer le site
La cadence est le premier levier anti-blocage, avant même la résolution des défis. Un passage toutes les quatre heures suffit pour la plupart des axes : les compagnies républient leurs grilles par lots, pas en continu. Décalez les itinéraires de quelques minutes plutôt que de lancer cinquante requêtes à la même seconde, et gardez le délai de courtoisie de check_fares.
# Check fares every 4 hours
0 */4 * * * cd /opt/fare-monitor && python fare_monitor.py
Dimensionner le budget : raisonner en threads, pas en résolutions
La facturation CaptchaAI porte sur les threads simultanés, avec des résolutions illimitées par thread. La question n'est donc pas « combien de CAPTCHA vais-je résoudre ? » mais « combien de défis auront lieu au même instant ? ». Un cron qui traite ses itinéraires en série n'occupe qu'une poignée de threads.
| Profil de veille | Itinéraires suivis | Relevés par jour | Défis attendus par jour | Formule adaptée |
|---|---|---|---|---|
| Veille personnelle | 5 | 6 par itinéraire | ~30 | BASIC ($15/mois, 5 threads) |
| Petite agence | 50 | 4 par itinéraire | ~200 | STANDARD ($30/mois, 15 threads) |
| Plateforme métier | 500 | 6 par itinéraire | ~3 000 | ADVANCE ($90/mois, 50 threads) |
Les volumes ci-dessus reposent sur des mesures observées et des retours d'utilisateurs ; les résultats varient selon l'environnement, le volume et le moment de la journée. La facturation est en dollars US.
Scénario : une agence lyonnaise qui suit 40 axes long-courriers
Une agence d'affaires lyonnaise surveille quarante axes Europe–Afrique de l'Ouest pour renégocier ses contrats corporate chaque trimestre. Le moniteur tourne chez un hébergeur européen, avec un passage à 6 h, 12 h, 18 h et minuit : les sites des compagnies servent surtout du reCAPTCHA v3, les deux OTA utilisées basculent en Cloudflare Turnstile dès le troisième relevé.
Le bilan après quelques semaines : la plupart des passages n'affichent aucun défi, et la formule STANDARD suffit parce que les quarante axes sont traités en série, avec trois threads occupés au maximum. Les montants restent dans la devise affichée par le site : comparer deux captures faites dans des devises différentes reste la première source de fausses alertes de baisse. Seuls l'itinéraire, la date et le montant sont conservés, ce qui garde la démarche simple à documenter côté RGPD.
Dépannage des pannes les plus fréquentes
| Problème | Cause probable | Correctif |
|---|---|---|
| Le token est accepté mais la page réaffiche le défi | Token injecté dans une session neuve | Réutiliser l'objet Session et ses cookies après la résolution |
CAPCHA_NOT_READY en boucle jusqu'au timeout |
Interrogation trop rapprochée ou charge élevée sur le type | Garder l'intervalle de 5 s et laisser la boucle aller à son terme |
| Aucun prix extrait alors que la page s'affiche | Grille rendue en JavaScript après le chargement | Récupérer la page avec Selenium ou Playwright, puis résoudre le défi via l'API |
| Alertes de baisse incohérentes | Devise ou marché qui change selon l'IP sortante | Fixer le marché dans l'URL et garder un proxy stable par itinéraire |
| Défis de plus en plus fréquents sur un même axe | Cadence trop régulière, empreinte de session identique | Décaler les passages de quelques minutes et alterner les proxys résidentiels |
FAQ
Quelle formule choisir pour surveiller 200 itinéraires par jour ?
STANDARD ($30/mois, 15 threads) suffit dans la plupart des cas. Le facteur limitant est la simultanéité, pas le volume : tant que le cron traite les itinéraires par petits lots, quinze threads absorbent les défis rencontrés.
CaptchaAI prend-il en charge hCaptcha sur les sites de voyage ?
Non — hCaptcha n'est pas encore pris en charge, pas plus que FunCaptcha (Arkose Labs). Si un site de votre périmètre bascule sur l'un de ces types, prévoyez une source de données alternative pour cet axe.
Faut-il des proxys résidentiels pour la veille tarifaire ?
Pas systématiquement. Un proxy datacenter stable convient pour des relevés espacés ; les proxys résidentiels deviennent utiles quand vous suivez plusieurs marchés géographiques ou que le défi revient à chaque passage.
Comment relever un prix sur une page rendue en JavaScript ?
Chargez la page dans Selenium ou Playwright, laissez le rendu se terminer, puis envoyez le sitekey à l'API et injectez le token avant de lire la grille.