Casos de Uso

Monitoreo de tarifas de viaje con manejo CAPTCHA

Para seguir el precio de un vuelo o un hotel de forma automática necesitas resolver el CAPTCHA que el sitio muestra en cuanto detecta un patrón no humano. Ese es el único paso que suele romper un monitor de tarifas casero: la lógica de scraping es trivial, pero la página devuelve un reCAPTCHA o un reto de Cloudflare antes de enseñarte el precio. CaptchaAI resuelve ese reto por API y te devuelve el token, de modo que tu script continúa como si un humano hubiera pasado la verificación.

Qué CAPTCHA usa cada tipo de sitio de viajes

No todos los sitios protegen sus precios igual. Antes de escribir una línea de código conviene saber contra qué defensa te vas a topar, porque eso determina el method que enviarás a la API. Todos los tipos de esta tabla son compatibles con CaptchaAI.

Categoría del sitio Tipo de CAPTCHA Dificultad
Aerolíneas (venta directa) reCAPTCHA v3, Cloudflare Media
OTA (Expedia, Booking) reCAPTCHA v2, Turnstile Media-alta
Metabuscadores (Google Flights, Kayak) reCAPTCHA v3 Media
Aerolíneas de bajo costo Image CAPTCHA, reCAPTCHA Baja-media
Agregadores de hoteles Cloudflare Challenge Alta

Cuánto cuesta monitorear tarifas

La ventaja de facturar por thread en lugar de por resolución es que el costo se vuelve predecible: pagas por concurrencia mensual en USD, no por cada CAPTCHA suelto. Para una agencia en Ciudad de México o un freelancer en Buenos Aires que factura en moneda local pero paga la herramienta en dólares, un monto fijo al mes es mucho más fácil de presupuestar que un pago por evento.

Nivel de monitoreo Rutas Consultas/día CAPTCHAs/día Costo est.
Personal 5 6/ruta ~30 $0.50
Agencia pequeña 50 4/ruta ~200 $2-5
Empresa 500 6/ruta ~3.000 $20-40

Con el plan BASIC ($15/mes, 5 threads) cubres holgadamente el escenario personal y buena parte del de una agencia pequeña, ya que cada thread queda libre para el siguiente reto en cuanto termina el anterior y no hay cargo por CAPTCHA resuelto.

Cómo construir el monitor de tarifas

La clase FareMonitor de abajo hace tres cosas: descarga la página de la ruta, detecta si hay un reCAPTCHA (v2 o v3) o un Turnstile en el HTML, y en ese caso envía el sitekey a CaptchaAI para recuperar el token y reenviarlo. Cuando la página responde limpia, extrae los precios con una expresión regular y guarda el historial para poder comparar y detectar bajadas.

Fíjate en el detalle de recaptcha/api.js?render=: esa cadena es la que distingue un reCAPTCHA v3 de un v2 sobre la misma página, y cambia el conjunto de parámetros que envías (version y action para v3). Envía siempre el token en el campo g-recaptcha-response para reCAPTCHA y en cf-turnstile-response para Turnstile.

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_changes(self, threshold_pct=5):
        """Detect significant price changes 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()

Programar las comprobaciones

Un monitor de tarifas solo es útil si corre solo. Una entrada de cron cada cuatro horas es un punto de partida sensato para uso personal: captura las actualizaciones de precio sin martillar el sitio ni desperdiciar threads en consultas que no van a cambiar nada. Recuerda respetar los términos de servicio del sitio y la normativa de protección de datos aplicable en tu país.

# Check fares every 4 hours
0 */4 * * * cd /opt/fare-monitor && python fare_monitor.py

Preguntas frecuentes

¿Qué diferencia hay entre resolver un reCAPTCHA v2 y un v3 en estas páginas?

El flujo de envío es casi idéntico, pero el v3 devuelve una puntuación en segundo plano y necesita los parámetros version y action, mientras que el v2 muestra el reto de imágenes clásico. El código detecta cuál es cada uno buscando recaptcha/api.js?render= en el HTML antes de decidir qué enviar.

¿El mismo monitor sirve para precios de hoteles?

Sí. La lógica de resolución de CAPTCHA no cambia entre vuelos y hoteles; solo tienes que ajustar la expresión regular de extracción de precios al formato de las fichas de Booking.com, Expedia o la web directa del hotel.

¿Y si la página carga los precios con JavaScript?

En ese caso el HTML inicial viene vacío de precios. Renderiza la página con Selenium o Playwright, extrae el sitekey del DOM ya cargado y pásalo a CaptchaAI; el token que recibes se inyecta igual que en el ejemplo.

¿Cuántos threads necesito para varias rutas a la vez?

Depende de cuántas consultas quieras solapar. Cada CAPTCHA en vuelo ocupa un thread hasta que se resuelve; con 5 threads del plan BASIC ($15/mes) cubres una decena de rutas comprobadas de forma escalonada sin cuellos de botella.

Guías relacionadas

Los comentarios están deshabilitados para este artículo.