Casos de Uso

Manejo de CAPTCHA para la recopilación de datos del mercado de valores

¿Tu recolector de cotizaciones se detiene con un desafío de Cloudflare cada pocas solicitudes? La causa casi siempre es la misma: los portales financieros disparan Cloudflare Turnstile y reCAPTCHA en cuanto detectan búsquedas de símbolos muy seguidas, descargas masivas de históricos o consultas de screener encadenadas. La salida práctica es delegar la resolución del CAPTCHA a una API mientras tu código conserva la sesión y el ritmo de peticiones. En esta guía verás cómo hacerlo con CaptchaAI sobre portales de estilo Yahoo Finance, la Bolsa de Madrid (IBEX 35) o la Bolsa Mexicana de Valores, con ejemplos reales en Python y JavaScript.

En esta guía verás, en orden:

  • qué CAPTCHA protege cada tipo de dato financiero y qué lo dispara;
  • con qué frecuencia conviene recopilar para no provocarlos;
  • el flujo de resolución paso a paso, con código listo en Python y JavaScript;
  • cómo diagnosticar los fallos más habituales.

Qué CAPTCHA aparece en cada portal financiero

Cada tipo de dato suele estar protegido por un CAPTCHA distinto según su frecuencia y volumen de consulta:

Tipo de dato Portales de ejemplo CAPTCHA habitual Qué lo dispara
Cotizaciones en tiempo real Portales financieros Cloudflare Turnstile Búsquedas de símbolos muy seguidas
Precios históricos Proveedores de datos reCAPTCHA v2 Descargas masivas en CSV
Estados financieros Portales tipo SEC/EDGAR CAPTCHA de imagen Consultas EDGAR repetidas
Resultados de screener Screeners de acciones Cloudflare Challenge Filtros complejos encadenados
Calificaciones de analistas Portales de análisis reCAPTCHA v3 Muchas vistas de página seguidas

Con qué frecuencia conviene recopilar cada dato

Ajustar el intervalo a lo que realmente cambia reduce cuántas veces te topas con un CAPTCHA. Define el ritmo antes de escribir una sola línea de scraper:

Tipo de dato Intervalo recomendado Frecuencia de CAPTCHA
Cotizaciones en tiempo real Cada 1–5 minutos Alta: usa una API oficial si está disponible
Precios de cierre Una vez al día tras el cierre Baja
Estados financieros Trimestral Mínima
Resultados de screener A diario Moderada
Calificaciones de analistas Semanal Baja

Cómo resuelve el flujo un CAPTCHA

El patrón es el mismo tanto en Python como en JavaScript y se reduce a cuatro pasos:

  1. Lanza la solicitud normal y comprueba si la respuesta es una página de desafío (código 403 o marca cf-turnstile en el HTML).
  2. Extrae el sitekey de la página y envía la tarea a CaptchaAI indicando el método (turnstile o userrecaptcha) y la URL.
  3. Sondea el resultado cada pocos segundos hasta que el token esté listo.
  4. Reenvía la solicitud original con el token resuelto y continúa con el parseo de los datos.

Recopilador de datos bursátiles en Python

El recopilador mantiene una sesión persistente, detecta la página de desafío y delega la resolución a CaptchaAI antes de reintentar la solicitud original. Si el portal usa reCAPTCHA en vez de Turnstile, cambia de método automáticamente.

import requests
import time
import re
from datetime import datetime, timedelta

class StockDataCollector:
    def __init__(self, api_key):
        self.api_key = api_key
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
        })

    def get_quote(self, portal_url, symbol):
        """Get current stock quote, solving CAPTCHAs if needed."""
        url = f"{portal_url}/quote/{symbol}"
        response = self.session.get(url)

        if self._is_captcha_page(response):
            response = self._solve_and_retry(response, url)

        return self._parse_quote(response.text, symbol)

    def get_historical(self, portal_url, symbol, days=365):
        """Download historical price data."""
        url = f"{portal_url}/history/{symbol}"
        params = {
            "period": f"{days}d",
            "interval": "1d"
        }
        response = self.session.get(url, params=params)

        if self._is_captcha_page(response):
            response = self._solve_and_retry(response, url)

        return self._parse_historical(response.text)

    def scan_symbols(self, portal_url, symbols, delay=2):
        """Collect quotes for multiple symbols."""
        results = {}

        for symbol in symbols:
            try:
                results[symbol] = self.get_quote(portal_url, symbol)
                time.sleep(delay)
            except Exception as e:
                results[symbol] = {"error": str(e)}

        return results

    def _is_captcha_page(self, response):
        return (
            response.status_code == 403 or
            "cf-turnstile" in response.text or
            "challenges.cloudflare.com" in response.text
        )

    def _solve_and_retry(self, response, url):
        match = re.search(r'data-sitekey="(0x[^"]+)"', response.text)
        if not match:
            # Fall back to reCAPTCHA detection
            match = re.search(r'data-sitekey="([^"]+)"', response.text)
            if match:
                return self._solve_recaptcha_and_retry(match.group(1), url)
            raise ValueError("No CAPTCHA sitekey found")

        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": self.api_key,
            "method": "turnstile",
            "sitekey": match.group(1),
            "pageurl": url,
            "json": 1
        })
        task_id = resp.json()["request"]

        for _ in range(60):
            time.sleep(3)
            result = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
                "json": 1
            })
            data = result.json()
            if data["status"] == 1:
                return self.session.post(url, data={
                    "cf-turnstile-response": data["request"]
                })

        raise TimeoutError("CAPTCHA solve timed out")

    def _solve_recaptcha_and_retry(self, site_key, url):
        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": self.api_key,
            "method": "userrecaptcha",
            "googlekey": site_key,
            "pageurl": url,
            "json": 1
        })
        task_id = resp.json()["request"]

        for _ in range(60):
            time.sleep(3)
            result = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
                "json": 1
            })
            data = result.json()
            if data["status"] == 1:
                return self.session.post(url, data={
                    "g-recaptcha-response": data["request"]
                })

        raise TimeoutError("reCAPTCHA solve timed out")

    def _parse_quote(self, html, symbol):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")

        def text_or_none(node):
          return node.text.strip() if node and node.text else None

        return {
            "symbol": symbol,
          "price": text_or_none(soup.select_one("[data-field='regularMarketPrice'], .price")),
          "change": text_or_none(soup.select_one("[data-field='regularMarketChange'], .change")),
          "volume": text_or_none(soup.select_one("[data-field='regularMarketVolume'], .volume")),
        "market_cap": text_or_none(soup.select_one("[data-field='marketCap'], .market-cap")),
            "timestamp": datetime.now().isoformat()
        }

    def _parse_historical(self, html):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")
        rows = []

        for row in soup.select("table tr")[1:]:  # Skip header
            cells = [td.text.strip() for td in row.select("td")]
            if len(cells) >= 6:
                rows.append({
                    "date": cells[0],
                    "open": cells[1],
                    "high": cells[2],
                    "low": cells[3],
                    "close": cells[4],
                    "volume": cells[5]
                })

        return rows


# Usage
collector = StockDataCollector("YOUR_API_KEY")

# Single quote
quote = collector.get_quote("https://finance.example.com", "AAPL")
print(f"AAPL: ${quote['price']} ({quote['change']})")

# Scan multiple symbols
portfolio = collector.scan_symbols(
    "https://finance.example.com",
    ["AAPL", "GOOGL", "MSFT", "AMZN", "TSLA"]
)

Screener de acciones con resolución de CAPTCHA (JavaScript)

El patrón es el mismo en Node.js: lanza la consulta con los filtros y, si la respuesta trae un Turnstile o un 403, resuelve y reenvía el formulario con el token incluido.

class MarketScreener {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }

  async screenStocks(portalUrl, filters) {
    const params = new URLSearchParams(filters);
    const response = await fetch(`${portalUrl}/screener?${params}`);
    const html = await response.text();

    if (html.includes('cf-turnstile') || response.status === 403) {
      return this.solveAndScreen(portalUrl, filters, html);
    }

    return this.parseScreenerResults(html);
  }

  async solveAndScreen(portalUrl, filters, html) {
    const match = html.match(/data-sitekey="(0x[^"]+)"/);
    if (!match) throw new Error('Turnstile sitekey not found');

    const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
      method: 'POST',
      body: new URLSearchParams({
        key: this.apiKey,
        method: 'turnstile',
        sitekey: match[1],
        pageurl: portalUrl,
        json: '1'
      })
    });
    const { request: taskId } = await submitResp.json();

    for (let i = 0; i < 60; i++) {
      await new Promise(r => setTimeout(r, 3000));
      const result = await fetch(
        `https://ocr.captchaai.com/res.php?key=${this.apiKey}&action=get&id=${taskId}&json=1`
      );
      const data = await result.json();
      if (data.status === 1) {
        const response = await fetch(`${portalUrl}/screener`, {
          method: 'POST',
          body: new URLSearchParams({
            ...filters,
            'cf-turnstile-response': data.request
          })
        });
        return this.parseScreenerResults(await response.text());
      }
    }
    throw new Error('Turnstile solve timed out');
  }

  parseScreenerResults(html) {
    const rows = [];
    const tableMatch = html.match(/<table[^>]*>[\s\S]*?<\/table>/i);
    if (!tableMatch) return rows;

    const rowMatches = tableMatch[0].matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi);
    for (const row of rowMatches) {
      const cells = [...row[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)]
        .map(m => m[1].replace(/<[^>]+>/g, '').trim());
      if (cells.length >= 4) {
        rows.push({
          symbol: cells[0],
          price: cells[1],
          change: cells[2],
          volume: cells[3]
        });
      }
    }
    return rows;
  }
}

// Usage
const screener = new MarketScreener('YOUR_API_KEY');
const results = await screener.screenStocks('https://finance.example.com', {
  sector: 'technology',
  marketCap: 'large',
  peRatio: '<25'
});

Problemas frecuentes y cómo resolverlos

Síntoma Causa Solución
Turnstile en cada solicitud Sesión nueva cada vez Persiste las cookies entre solicitudes
Históricos incompletos Paginación detrás del CAPTCHA Resuelve por página y sigue los enlaces paginados
Cotizaciones obsoletas Respuesta servida desde caché Añade un parámetro anti-caché a la consulta
Error 429 (límite de solicitudes) Demasiadas solicitudes seguidas Aumenta el retraso y reparte la carga entre salidas de red autorizadas

Buenas prácticas para disparar menos CAPTCHA

La forma más barata de resolver un CAPTCHA es no provocarlo. Antes de subir el volumen de resoluciones, revisa estos puntos:

  • Reutiliza una sola sesión con cookies persistentes en lugar de abrir una nueva por cada símbolo.
  • Respeta el intervalo de la tabla de frecuencia: sondear cotizaciones cada segundo dispara el desafío casi siempre.
  • Añade un retraso corto y algo de aleatoriedad entre solicitudes en vez de lanzarlas en ráfaga.
  • Cachea los datos que cambian poco (estados financieros, calificaciones) y consúltalos solo cuando toca.
  • Respeta los términos de servicio del portal y la normativa de protección de datos aplicable.

Preguntas frecuentes

¿Qué plan de CaptchaAI necesito para vigilar muchos símbolos a la vez?

Depende de cuántas solicitudes corran en paralelo, no del número de símbolos. CaptchaAI factura por threads concurrentes con resoluciones ilimitadas dentro de cada plan. Para un vigilante ligero, BASIC ($15/mes, 5 threads) suele bastar; si sondeas cotizaciones en tiempo real, STANDARD ($30/mes, 15 threads) o ADVANCE ($90/mes, 50 threads) te dan más solicitudes en paralelo.

¿CaptchaAI resuelve Cloudflare Turnstile y reCAPTCHA en el mismo scraper?

Sí. El flujo detecta el sitekey en la página: si empieza por 0x lo trata como Cloudflare Turnstile y, si no, recurre a reCAPTCHA con el método userrecaptcha. CaptchaAI resuelve reCAPTCHA v2/v3, Cloudflare Turnstile y Cloudflare Challenge, GeeTest v3 e imágenes/OCR.

Depende de la jurisdicción y de los términos de cada portal. Recopilar cotizaciones públicas suele estar permitido, pero respeta siempre los términos de servicio y la normativa de protección de datos aplicable (GDPR y LOPDGDD en España, LFPDPPP en México). Evita datos personales y no sobrecargues el servidor de origen.

¿Cómo reduzco la frecuencia con la que aparece el CAPTCHA?

Reutiliza la sesión. Tras resolver el desafío, la cookie de validación de Cloudflare se mantiene válida un rato (unos 15–30 minutos según el sitio), así que resuelve una vez y agrupa varias solicitudes dentro de esa ventana antes de que el portal vuelva a pedir verificación.

Artículos relacionados

Próximos pasos

Empieza a recopilar datos de mercado sin interrupciones: consigue tu clave API de CaptchaAI y deja que la resolución de los CAPTCHA de los portales financieros corra en automático.

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