Casos de Uso

Manejo de CAPTCHA para la recopilación de datos sobre salarios y compensaciones

Armar una tabla de bandas salariales por rol y ciudad casi nunca llega a la fila 50 sin toparse con un CAPTCHA. Los comparadores de sueldos, las bolsas de empleo y los portales laborales públicos protegen sus datos de compensación con Cloudflare Turnstile, reCAPTCHA y desafíos de imagen, y el reto salta justo cuando lanzas muchas consultas seguidas por puesto, ubicación o industria. La respuesta corta: delega la resolución a una API, mantén la sesión viva entre búsquedas y espacia las solicitudes. Esta guía muestra cómo hacerlo con CaptchaAI, con código listo para copiar en Python y JavaScript.

Dónde se activan los CAPTCHA en los portales de salarios

Antes de escribir una línea de código conviene saber qué tipo de reto vas a encontrar en cada fuente. El disparador casi siempre es el mismo patrón: un volumen de consultas por encima de lo que haría una persona navegando. Esta tabla resume lo que verás con más frecuencia.

Tipo de fuente CAPTCHA Qué lo activa
Comparadores de salarios Cloudflare Turnstile Búsquedas repetidas seguidas
Filtros salariales en bolsas de empleo reCAPTCHA v2 Varias consultas de sueldo
Estadísticas laborales públicas CAPTCHA de imagen Descargas de conjuntos de datos
Páginas de compensación corporativas Cloudflare Challenge Muchas vistas de página
Plataformas de encuestas de RR. HH. reCAPTCHA v3 Envío de formularios

CaptchaAI resuelve los cinco tipos de esta tabla: Turnstile, Cloudflare Challenge, reCAPTCHA v2 y v3, y los CAPTCHA de imagen (OCR). En la práctica, Turnstile es el que más aparece en los comparadores de sueldos, así que el resto de la guía lo toma como caso principal.

Recopilador de sueldos en Python con resolución automática

El siguiente recopilador hace una búsqueda por combinación de puesto y ubicación, detecta cuándo el portal responde con un desafío Turnstile y, en ese caso, envía el sitekey a CaptchaAI, sondea el resultado y reintenta la solicitud con el token ya resuelto. El bucle collect_bulk recorre todos los pares de rol y ciudad y deja un margen entre solicitudes para no saturar el portal.

import requests
import time
import re
from dataclasses import dataclass

@dataclass
class SalaryRecord:
    title: str
    location: str
    min_salary: float
    max_salary: float
    median_salary: float
    sample_size: int
    source: str

class SalaryCollector:
    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 collect_salary_data(self, portal_url, job_title, location):
        """Search for salary data, solving CAPTCHAs as needed."""
        response = self.session.get(portal_url, params={
            "title": job_title,
            "location": location
        })

        if self._is_turnstile_challenge(response):
            response = self._solve_turnstile_and_retry(response, portal_url)

        return self._parse_salary_data(response.text, portal_url)

    def collect_bulk(self, portal_url, job_titles, locations):
        """Collect salary data for multiple job title + location combos."""
        results = []

        for title in job_titles:
            for location in locations:
                try:
                    data = self.collect_salary_data(
                        portal_url, title, location
                    )
                    results.extend(data)
                    # Respectful delay between requests
                    time.sleep(2)
                except Exception as e:
                    print(f"Failed for {title} in {location}: {e}")

        return results

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

    def _solve_turnstile_and_retry(self, response, url):
        match = re.search(r'data-sitekey="(0x[^"]+)"', response.text)
        if not match:
            raise ValueError("Turnstile sitekey not 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("Turnstile solve timed out")

    def _parse_salary_data(self, html, source):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")
        records = []

        def text_or_empty(node):
            return node.text.strip() if node and node.text else ""

        for row in soup.select(".salary-row, .compensation-entry, tr[data-salary]"):
            try:
                records.append(SalaryRecord(
            title=text_or_empty(row.select_one(".job-title, .title")),
            location=text_or_empty(row.select_one(".location")),
                    min_salary=self._parse_amount(
              text_or_empty(row.select_one(".min-salary, .low"))
                    ),
                    max_salary=self._parse_amount(
              text_or_empty(row.select_one(".max-salary, .high"))
                    ),
                    median_salary=self._parse_amount(
              text_or_empty(row.select_one(".median, .mid"))
                    ),
                    sample_size=int(
              text_or_empty(row.select_one(".count, .sample")).replace(",", "") or 0
                    ),
                    source=source
                ))
            except (AttributeError, ValueError):
                continue

        return records

    def _parse_amount(self, text):
        if not text:
            return 0.0
        cleaned = re.sub(r'[^\d.]', '', text)
        return float(cleaned) if cleaned else 0.0


# Usage
collector = SalaryCollector("YOUR_API_KEY")
data = collector.collect_bulk(
    "https://salary.example.com/search",
    job_titles=["Software Engineer", "Data Analyst", "Product Manager"],
    locations=["San Francisco", "New York", "Austin"]
)

for record in data:
    print(f"{record.title} in {record.location}: "
          f"${record.min_salary:,.0f}–${record.max_salary:,.0f} "
          f"(median: ${record.median_salary:,.0f})")

Agregar varias fuentes en un solo resultado (JavaScript)

Un solo comparador rara vez basta: cada fuente muestra rangos distintos para el mismo rol. Este agregador en JavaScript consulta varias fuentes, resuelve el Turnstile de cada una por separado y combina las medianas en un único valor por rol y ciudad. Registra los errores por fuente en lugar de abortar todo el lote cuando una falla.

class SalaryAggregator {
  constructor(apiKey) {
    this.apiKey = apiKey;
    this.sources = [];
  }

  addSource(name, searchUrl) {
    this.sources.push({ name, searchUrl });
  }

  async collectForRole(jobTitle, location) {
    const results = [];

    for (const source of this.sources) {
      try {
        const data = await this.querySource(source, jobTitle, location);
        results.push({ source: source.name, ...data });
      } catch (error) {
        results.push({ source: source.name, error: error.message });
      }
    }

    return this.aggregateResults(results, jobTitle, location);
  }

  async querySource(source, jobTitle, location) {
    const url = `${source.searchUrl}?title=${encodeURIComponent(jobTitle)}&location=${encodeURIComponent(location)}`;
    const response = await fetch(url);
    const html = await response.text();

    if (html.includes('cf-turnstile') || response.status === 403) {
      return this.solveAndRetry(source.searchUrl, html, jobTitle, location);
    }

    return this.parseSalaryData(html);
  }

  async solveAndRetry(baseUrl, html, jobTitle, location) {
    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: baseUrl,
        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(baseUrl, {
          method: 'POST',
          body: new URLSearchParams({
            'cf-turnstile-response': data.request,
            title: jobTitle,
            location: location
          })
        });
        return this.parseSalaryData(await response.text());
      }
    }
    throw new Error('Turnstile solve timed out');
  }

  aggregateResults(results, jobTitle, location) {
    const valid = results.filter(r => !r.error && r.median);
    if (valid.length === 0) return null;

    const medians = valid.map(r => r.median);
    return {
      jobTitle,
      location,
      avgMedian: medians.reduce((a, b) => a + b, 0) / medians.length,
      sources: valid.length,
      range: { min: Math.min(...medians), max: Math.max(...medians) }
    };
  }
}

// Usage
const aggregator = new SalaryAggregator('YOUR_API_KEY');
aggregator.addSource('SalaryDB', 'https://salarydb.example.com/search');
aggregator.addSource('PayScale', 'https://payscale.example.com/lookup');

const result = await aggregator.collectForRole('Software Engineer', 'San Francisco');
console.log(`Median salary: $${result.avgMedian.toLocaleString()} (${result.sources} sources)`);

Cómo escalar la recopilación sin saturar el portal

El volumen que puedes recoger al día depende sobre todo del límite de solicitudes del propio portal, no de CaptchaAI. Empieza secuencial y sube de nivel solo cuando el tamaño del conjunto de datos lo justifique.

Enfoque Volumen por día Frecuencia de CAPTCHA Ideal para
Secuencial con esperas 100–500 consultas Baja Muestreos pequeños
Reparto por salidas de red 500–2.000 consultas Moderada Análisis por región
Sesiones paralelas 2.000–10.000 consultas Alta Conjuntos de datos completos

Un caso típico: una consultora de compensación en Madrid o Ciudad de México que arma bandas salariales para roles tech de toda la región, cruzando comparadores públicos con estadísticas laborales oficiales. Con un volumen de unos pocos cientos de consultas al día, el plan BASIC ($15/mes, 5 threads) sobra; cuando el proyecto pasa a refrescar miles de combinaciones de rol y ciudad, el plan ADVANCE ($90/mes, 50 threads) resuelve varios Turnstile en paralelo sin cola. La ventaja para una agencia que factura en moneda local volátil es un costo mensual predecible en USD, en lugar de pagar por cada resolución. Recuerda respetar los términos de servicio de cada portal y la normativa de protección de datos aplicable (GDPR y LOPDGDD en España, LFPDPPP en México): recopila solo datos de compensación públicos.

Errores comunes y cómo resolverlos

Problema Causa Solución
Aparece Turnstile en cada búsqueda La sesión expiró Conserva la cookie de sesión (qa_validation_cookie) entre consultas
Los datos muestran "Se requiere iniciar sesión" El portal exige autenticación Autentícate antes de lanzar la búsqueda
Resultados vacíos tras resolver el CAPTCHA Faltan parámetros del POST Incluye todos los campos ocultos del formulario
Datos inconsistentes entre ejecuciones El portal devuelve rangos distintos Usa siempre los mismos parámetros de consulta

Preguntas frecuentes

¿Qué tipos de CAPTCHA de los portales de salarios resuelve CaptchaAI?

Los que verás en la práctica: Cloudflare Turnstile y Cloudflare Challenge, reCAPTCHA v2 y v3, y los CAPTCHA de imagen (OCR) que usan varias estadísticas laborales públicas. No resuelve hCaptcha ni FunCaptcha, así que si una fuente concreta usa uno de esos, tendrás que descartarla o buscar una alternativa.

¿Cuánto cuesta resolver CAPTCHA en un proyecto de datos salariales?

CaptchaAI cobra por thread concurrente, no por resolución, con resoluciones ilimitadas dentro de cada thread. Para un muestreo de unos cientos de consultas al día basta el plan BASIC ($15/mes, 5 threads); un proyecto que refresca miles de combinaciones de rol y ciudad encaja mejor en ADVANCE ($90/mes, 50 threads). Al no pagar por cada resolución, el costo mensual en USD es predecible aunque el volumen suba.

¿Con qué frecuencia conviene actualizar los datos de compensación?

Casi siempre basta con ejecuciones semanales o mensuales. La mayoría de los comparadores y encuestas salariales refrescan sus rangos cada mes o cada trimestre, así que recolectar en tiempo real no aporta datos nuevos y solo dispara más CAPTCHA. Programa lotes periódicos y guarda el histórico para detectar tendencias.

¿Por qué me sale un Turnstile en cada búsqueda?

Casi siempre es porque la sesión no se conserva entre solicitudes. Reutiliza la misma sesión HTTP y su cookie de validación, deja un margen de 2 a 5 segundos entre consultas y reparte la carga entre varias salidas de red autorizadas. Con eso, el portal deja de tratar cada búsqueda como una visita nueva y el CAPTCHA aparece con mucha menos frecuencia.

Artículos relacionados

Próximos pasos

Reúne datos de compensación sin frenar en cada desafío: obtén tu clave API de CaptchaAI y resuelve automáticamente el Turnstile de los portales salariales desde tu primer lote.

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