¿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 |
| 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 Turnstileg-recaptcha— clase del widget de reCAPTCHA v2cf-turnstile— marcador del widget de Cloudflare Turnstilecaptcha-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
- Proxies residenciales rotativos para scraping
- Cómo la calidad del proxy afecta la tasa de resolución
- Sesiones fijas frente a rotativas
Recopila datos del mercado laboral a escala: crea tu cuenta en CaptchaAI y automatiza la resolución de CAPTCHA dentro de tu pipeline.