Casos de Uso

Scraping de portales de empleo con manejo de CAPTCHA

¿Estás recopilando ofertas de empleo a escala y el portal te devuelve un CAPTCHA en cuanto subes el ritmo de las peticiones? La respuesta corta es esta: detecta el reto en la respuesta HTML, resuélvelo con la API de CaptchaAI y reanuda el scraping sin abandonar la sesión. Indeed, LinkedIn, Glassdoor y compañía activan reCAPTCHA o Cloudflare cuando huelen tráfico automatizado, así que un scraper de datos laborales serio necesita tratar el CAPTCHA como un paso más del flujo, no como un muro final.

Este patrón sirve tanto para portales globales como para bolsas regionales —InfoJobs y el SEPE en España, o Computrabajo y Bumeran en Latinoamérica—: la mecánica es la misma, cambia el sitekey y el selector de resultados. Recuerda revisar los términos de servicio de cada plataforma y la normativa de protección de datos aplicable antes de recolectar a gran escala.


Qué CAPTCHA usa cada portal de empleo

Antes de escribir una línea de código conviene saber a qué te enfrentas. Cada portal despliega un tipo de reto distinto y lo activa por motivos diferentes:

Portal Tipo de CAPTCHA Qué lo activa Datos disponibles
Indeed reCAPTCHA v2 Alto volumen de solicitudes Ofertas y salarios
LinkedIn Cloudflare Challenge Detección de bots Empleos y datos de empresa
Glassdoor reCAPTCHA v2 Detección de scraping Reseñas, salarios y empleos
ZipRecruiter Cloudflare Turnstile Acceso automatizado Ofertas de empleo
Monster reCAPTCHA v2 Páginas de búsqueda Ofertas de empleo
CareerBuilder reCAPTCHA v3 Login y búsqueda Empleos y búsqueda de CV

CaptchaAI resuelve todos los tipos de esta tabla —reCAPTCHA v2 y v3, Cloudflare Turnstile y Cloudflare Challenge—, así que un mismo scraper puede cubrir las seis plataformas con la misma llamada a la API.


Configuración recomendada para un scraping estable

El manejo del CAPTCHA es solo la mitad del trabajo. La otra mitad es no provocarlo más de lo necesario. Estas prácticas reparten la carga y mantienen la sesión por debajo de los umbrales de detección:

Técnica Por qué ayuda
Proxies residenciales rotativos Reparte las solicitudes entre IP reales
Retrasos de 3 a 5 segundos entre páginas Imita el ritmo de navegación de una persona
User-Agent constante por sesión Evita inconsistencias en las señales del navegador
Aceptar cookies Los portales rastrean la sesión mediante cookies
Aleatorizar el orden de búsqueda Evita patrones de páginas secuenciales
Límite de 200 páginas/día por dominio Mantente por debajo de los umbrales de detección

Scraper de empleo con resolución de CAPTCHA integrada

El scraper detecta el CAPTCHA en el HTML antes de analizarlo. Las señales que delatan un reto en la respuesta son pocas y estables:

  • data-sitekey= — atributo del contenedor de reCAPTCHA o Turnstile
  • g-recaptcha — clase del widget de reCAPTCHA v2
  • cf-turnstile — marcador del widget de Cloudflare Turnstile
  • captcha-delivery — endpoint típico de los retos servidos por proxy

Con esos marcadores decides si es reCAPTCHA o Turnstile y reenvías la petición con el token ya resuelto. La función solve_captcha envía la tarea a in.php y sondea res.php hasta recibir el resultado:

import requests
import time
import re
from bs4 import BeautifulSoup

CAPTCHAAI_KEY = "YOUR_API_KEY"
CAPTCHAAI_URL = "https://ocr.captchaai.com"


def solve_captcha(method, sitekey, pageurl, **kwargs):
    data = {
        "key": CAPTCHAAI_KEY,
        "method": method,
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": 1,
    }
    data.update(kwargs)
    resp = requests.post(f"{CAPTCHAAI_URL}/in.php", data=data)
    task_id = resp.json()["request"]

    for _ in range(60):
        time.sleep(5)
        result = requests.get(f"{CAPTCHAAI_URL}/res.php", params={
            "key": CAPTCHAAI_KEY, "action": "get",
            "id": task_id, "json": 1,
        })
        r = result.json()
        if r["request"] != "CAPCHA_NOT_READY":
            return r["request"]
    raise TimeoutError("Solve timeout")


class JobBoardScraper:
    def __init__(self, proxy=None):
        self.session = requests.Session()
        if proxy:
            self.session.proxies = {"http": proxy, "https": proxy}
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
            "Accept-Language": "en-US,en;q=0.9",
        })

    def search_jobs(self, base_url, query, location, pages=5):
        """Search job listings across multiple pages."""
        all_jobs = []

        for page in range(pages):
            url = f"{base_url}/jobs?q={query}&l={location}&start={page * 10}"
            resp = self.session.get(url, timeout=30)

            # Check for CAPTCHA
            if self._has_captcha(resp.text):
                resp = self._solve_and_retry(resp.text, url)

            if resp.status_code == 200:
                jobs = self._parse_listings(resp.text)
                all_jobs.extend(jobs)
                print(f"Page {page + 1}: {len(jobs)} jobs found")
            else:
                print(f"Page {page + 1}: Request failed ({resp.status_code})")

            time.sleep(3)  # Rate limit

        return all_jobs

    def _has_captcha(self, html):
        indicators = [
            'data-sitekey=',
            'g-recaptcha',
            'cf-turnstile',
            'captcha-delivery',
        ]
        return any(ind in html.lower() for ind in indicators)

    def _solve_and_retry(self, html, url):
        # Try reCAPTCHA first
        match = re.search(r'data-sitekey="([^"]+)"', html)
        if match:
            sitekey = match.group(1)

            # Detect Turnstile vs reCAPTCHA
            if 'cf-turnstile' in html:
                token = solve_captcha("turnstile", sitekey, url)
                field = "cf-turnstile-response"
            else:
                token = solve_captcha("userrecaptcha", sitekey, url)
                field = "g-recaptcha-response"

            return self.session.post(url, data={field: token})

        return self.session.get(url)

    def _parse_listings(self, html):
        soup = BeautifulSoup(html, "html.parser")
        jobs = []

        for card in soup.select(".job_seen_beacon, .jobsearch-ResultsList > li"):
            title_el = card.select_one("h2 a, .jobTitle a")
            company_el = card.select_one(".companyName, [data-testid='company-name']")
            location_el = card.select_one(".companyLocation, [data-testid='text-location']")
            salary_el = card.select_one(".salary-snippet, .estimated-salary")

            if title_el:
                jobs.append({
                    "title": title_el.get_text(strip=True),
                    "company": company_el.get_text(strip=True) if company_el else "",
                    "location": location_el.get_text(strip=True) if location_el else "",
                    "salary": salary_el.get_text(strip=True) if salary_el else "",
                    "url": title_el.get("href", ""),
                })

        return jobs


# Usage
scraper = JobBoardScraper(
    proxy="http://user:pass@residential.proxy.com:5000"
)
jobs = scraper.search_jobs(
    base_url="https://jobs.example.com",
    query="python developer",
    location="New York",
    pages=10,
)
print(f"Total jobs collected: {len(jobs)}")

Fíjate en _solve_and_retry: primero extrae el sitekey de la página y, según encuentre el marcador cf-turnstile o no, elige entre turnstile y userrecaptcha. El token vuelve en el campo correcto (cf-turnstile-response o g-recaptcha-response) y la sesión reenvía la petición como si el reto lo hubiera pasado una persona.


Recopilar datos salariales para análisis de mercado

Una vez que el scraper base funciona, envolverlo en un barrido de cargos y ubicaciones te da una tabla de salarios lista para exportar a CSV. Es el caso típico de una consultora de RR. HH. o un equipo de estudios de mercado que necesita comparar retribuciones por rol y ciudad:

import csv


def collect_salary_data(titles, locations, output_file):
    """Collect salary data across job titles and locations."""
    scraper = JobBoardScraper(
        proxy="http://user:pass@residential.proxy.com:5000"
    )

    results = []
    for title in titles:
        for location in locations:
            try:
                jobs = scraper.search_jobs(
                    "https://jobs.example.com",
                    title, location, pages=3,
                )
                salaries = [j["salary"] for j in jobs if j["salary"]]
                results.append({
                    "title": title,
                    "location": location,
                    "listings": len(jobs),
                    "with_salary": len(salaries),
                    "salary_samples": "; ".join(salaries[:5]),
                })
                time.sleep(5)
            except Exception as e:
                results.append({
                    "title": title,
                    "location": location,
                    "error": str(e),
                })

    with open(output_file, "w", newline="") as f:
        writer = csv.DictWriter(
            f, fieldnames=["title", "location", "listings",
                           "with_salary", "salary_samples", "error"],
        )
        writer.writeheader()
        writer.writerows(results)

    return results


# Collect salary data for market analysis
collect_salary_data(
    titles=["Data Engineer", "ML Engineer", "DevOps Engineer"],
    locations=["San Francisco", "New York", "Austin", "Remote"],
    output_file="salary_data.csv",
)

El bucle captura las excepciones por combinación de cargo y ubicación, de modo que un bloqueo puntual no tira todo el barrido: la fila queda registrada con su error y el proceso continúa.


Problemas frecuentes y cómo resolverlos

Cuando el scraper deja de traer datos, el motivo casi siempre está en esta tabla:

Problema Causa Solución
CAPTCHA en cada búsqueda IP marcada o frecuencia excedida Cambia de IP y añade retrasos más largos
Página de resultados vacía Se devolvió un bloqueo CAPTCHA en su lugar Detecta el CAPTCHA antes de analizar el HTML
"Por favor, verifica que eres humano" Se activó la detección de bots Usa una salida de red autorizada + UA realista
Login requerido para ver salarios El portal restringe ese contenido Implementa una sesión autenticada
Resultados distintos a los del navegador Diferencias de ubicación o cookies Ajusta Accept-Language y el proxy por geografía

Preguntas frecuentes

¿Qué plan de CaptchaAI necesito para hacer scraping de empleo a escala?

Depende de cuántas búsquedas corran en paralelo. CaptchaAI factura por threads concurrentes con resoluciones ilimitadas dentro del plan, no por cada CAPTCHA. Para un scraper de un solo proceso, el plan BASIC ($15/mes, 5 threads) es suficiente; si lanzas barridos paralelos por varios portales a la vez, ADVANCE ($90/mes, 50 threads) da mucho más margen.

¿Cómo sé si un portal me devolvió un CAPTCHA en lugar de los resultados?

Revisa el HTML antes de analizarlo. Marcadores como data-sitekey=, g-recaptcha o cf-turnstile en la respuesta indican que llegó un reto y no la lista de ofertas. El método _has_captcha del ejemplo hace exactamente esa comprobación antes de pasar el HTML a BeautifulSoup.

¿CaptchaAI resuelve el Cloudflare Challenge que usa LinkedIn?

Sí. Cloudflare Challenge y Cloudflare Turnstile son tipos compatibles, igual que reCAPTCHA v2 y v3. Envías el sitekey y la URL de la página, y recibes el token que reenvías en el campo correspondiente.

¿Puedo hacer scraping de portales de empleo respetando las reglas?

En parte depende de ti. Muchos portales restringen el acceso automatizado en sus términos y su aplicación varía, así que limita el ritmo, evita datos tras un login que no te pertenece y respeta la normativa de protección de datos aplicable (GDPR y LOPDGDD en la UE, entre otras).


Guías relacionadas


Recopila datos del mercado laboral a escala: crea tu cuenta en CaptchaAI y automatiza la resolución de CAPTCHA dentro de tu pipeline.

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