Scraping por lenguaje 21 min de lectura

Web scraping en Java: guía completa

Guía completa de web scraping en Java: Jsoup, HtmlUnit, Selenium, multithreading y arquitectura industrial de scrapers y crawlers.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 26 marzo 2025

Guía general sobre cómo extraer datos de páginas web en Java — desde la descarga simple de una sola página hasta un crawler multihilo con proxy, TOR y la resolución de los problemas más habituales. La estructura sigue un orden «de lo simple a lo complejo»: cada sección se apoya en la anterior.


Índice

  1. Introducción: qué es el web scraping y su marco legal
  2. Cómo descargamos la página: clientes HTTP
  3. Bibliotecas para el parseo del contenido
  4. Resolución de problemas con acentos y la ñ (codificaciones)
  5. Obtención del estado de la respuesta y las cabeceras
  6. Trabajo con cookies
  7. Trabajo con HTTPS/SSL
  8. Uso de proxies
  9. Scraping a través de TOR
  10. Multithreading
  11. Renderizado de JavaScript: sitios dinámicos
  12. Protección anti-bot: User-Agent, retardos, reintentos
  13. Almacenamiento de URLs y colas
  14. Frameworks de crawling listos para usar
  15. Ventajas e inconvenientes de la implementación en Java

1. Introducción

El web scraping es la extracción automatizada de datos de páginas web. El proceso se divide, a grandes rasgos, en dos fases que conviene no confundir:

  • Descarga (fetching / crawling) — obtener el HTML (o JSON/XML) por HTTP. De esto se encarga el cliente HTTP.
  • Análisis (parsing / extraction) — convertir el HTML «en bruto» en una estructura y seleccionar los datos que interesan mediante selectores. De esto se encarga el parser.

Marco legal y ético

Antes de escribir una sola línea de código conviene tener presentes varias cosas; no es asesoramiento jurídico, sino una higiene mínima:

  • robots.txt — archivo en la raíz del sitio (https://site.com/robots.txt) donde el propietario indica qué pueden rastrear los robots. Casi nunca tiene valor jurídico, pero ignorarlo es de mala educación y un desencadenante habitual de bloqueos.
  • Las condiciones de uso (ToS) del sitio pueden prohibir expresamente la recolección automática. Incumplirlas supone un riesgo contractual y, en algunas jurisdicciones, de mayor calado.
  • Datos personales. La recogida de datos personales está regulada por ley (en la UE, el RGPD). Actúe con cautela.
  • Carga. Un scraping agresivo equivale a un DoS contra el servidor ajeno. Introduzca retardos, limite el número de hilos y respete la cabecera Retry-After y los códigos 429/503.

Técnicamente, Java permite casi cualquier cosa. La responsabilidad sobre «qué» se extrae y «con qué fin» recae en usted.


2. Cómo descargamos la página

Es el cimiento: sin una respuesta correctamente obtenida no hay nada que analizar. Java ofrece varias opciones de cliente HTTP.

2.1. java.net.http.HttpClient integrado (Java 11+)

El cliente estándar moderno. No requiere dependencias y admite HTTP/2 y los modos síncrono y asíncrono.

java
import java.net.URI;
import java.net.http.*;
import java.time.Duration;

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(10))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .version(HttpClient.Version.HTTP_2)
        .build();

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://example.com"))
        .timeout(Duration.ofSeconds(15))
        .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
        .header("Accept-Language", "es-ES,es;q=0.9")
        .GET()
        .build();

HttpResponse<String> response =
        client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println("Status: " + response.statusCode());
String html = response.body();

Un matiz importante sobre la codificación: BodyHandlers.ofString() sin argumento decodifica el cuerpo como UTF-8. Si el sitio usa otra codificación (por ejemplo, ISO-8859-1), aparecerán caracteres ilegibles (mojibake) — de esto trata la sección 4. Para evitarlo, es habitual obtener los bytes y determinar la codificación por separado:

java
HttpResponse<byte[]> resp =
        client.send(request, HttpResponse.BodyHandlers.ofByteArray());
byte[] raw = resp.body();   // decodificamos después, conociendo el charset

2.2. OkHttp

Biblioteca de terceros muy popular (Square). API cómoda, pools de conexiones, interceptores y un manejo sencillo de proxies y cookies. Una buena opción «por defecto» para el scraping serio.

java
// build.gradle: implementation("com.squareup.okhttp3:okhttp:4.12.0")
import okhttp3.*;

OkHttpClient client = new OkHttpClient.Builder()
        .connectTimeout(Duration.ofSeconds(10))
        .readTimeout(Duration.ofSeconds(15))
        .build();

Request request = new Request.Builder()
        .url("https://example.com")
        .header("User-Agent", "Mozilla/5.0 ...")
        .build();

try (Response response = client.newCall(request).execute()) {
    int code = response.code();
    byte[] bytes = response.body().bytes();   // de nuevo: mejor los bytes
}

2.3. Apache HttpClient 5

Biblioteca madura, potente y algo más verbosa. Permite un ajuste fino de conexiones, pools y autenticación. Habitual en entornos empresariales.

java
// org.apache.httpcomponents.client5:httpclient5:5.x
try (CloseableHttpClient httpclient = HttpClients.createDefault()) {
    HttpGet httpGet = new HttpGet("https://example.com");
    httpclient.execute(httpGet, response -> {
        int status = response.getCode();
        byte[] body = EntityUtils.toByteArray(response.getEntity());
        return body;
    });
}

2.4. Descarga directa con jsoup

jsoup (véase la sección 3) puede descargar la página por sí mismo. Resulta cómodo para prototipos, pero su motor HTTP integrado es menos flexible (proxies, pools, ajuste fino), así que para tareas de producción la combinación habitual es: descargar con un cliente potente → analizar con jsoup.

java
Document doc = Jsoup.connect("https://example.com")
        .userAgent("Mozilla/5.0 ...")
        .timeout(15_000)
        .get();

Cuál elegir

Cliente Cuándo usarlo
java.net.http.HttpClient Sin ganas de añadir dependencias, Java 11+, HTTP/2
OkHttp Opción universal; proxies/cookies/interceptores cómodos
Apache HttpClient 5 Empresa, control fino, autenticación compleja
jsoup .connect() Prototipos y tareas simples de «descargar y analizar»

3. Bibliotecas para el parseo

Una vez obtenido el HTML, hay que extraer los datos. No conviene analizarlo con expresiones regulares: el HTML no es un lenguaje regular y un parser así se rompe ante cualquier marcado no estándar.

3.1. jsoup: el caballo de batalla

jsoup (versión actual en 2026: 1.22.2) implementa la especificación WHATWG HTML5 y construye el mismo DOM que los navegadores. Admite selectores CSS y XPath, y tolera el HTML «sucio».

java
// implementation("org.jsoup:jsoup:1.22.2")
import org.jsoup.Jsoup;
import org.jsoup.nodes.*;
import org.jsoup.select.Elements;

Document doc = Jsoup.parse(html, "https://example.com"); // 2.º arg — baseUri para enlaces absolutos

// selectores CSS
Elements links = doc.select("a[href]");
for (Element link : links) {
    String text = link.text();
    String absUrl = link.absUrl("href");  // URL absoluta
}

// selección puntual
Element title = doc.selectFirst("h1.article-title");
String price = doc.select("span.price").text();

// atributos
String img = doc.selectFirst("img").attr("src");

Algunas técnicas habituales con selectores:

java
doc.select("div.product");              // por clase
doc.select("#main-content");            // por id
doc.select("ul.menu > li");             // hijos directos
doc.select("a[href^=https]");           // el atributo empieza por
doc.select("table tr:nth-child(2n)");   // pseudoselectores
doc.select("p:contains(Precio)");       // por texto

3.2. HtmlUnit: el navegador headless

HtmlUnit es un navegador sin interfaz gráfica (GUI-less) escrito en Java. Ejecuta JavaScript (de forma limitada), lo que a veces permite obtener datos de sitios dinámicos sin recurrir al pesado Selenium. Es más lento que jsoup, pero más potente.

3.3. Cuando el HTML no hace falta

Con mucha frecuencia, los datos de la página se cargan mediante una petición aparte a una API interna (JSON). Abra las DevTools → pestaña Network, localice el XHR/fetch correspondiente y parsee el JSON puro con Jackson o Gson. Es mucho más fiable y rápido que analizar el HTML. Compruebe siempre esta vía primero.

java
// com.fasterxml.jackson.core:jackson-databind
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(jsonString);
String name = root.path("data").path("name").asText();

4. Acentos, la ñ y codificaciones

El dolor de cabeza más común en la web hispanohablante es el mojibake (José, canción o ���). La causa es siempre la misma: los bytes se han decodificado con una codificación equivocada.

Por qué ocurre

HTTP devuelve bytes. Para obtener una cadena hay que decodificarlos con la codificación correcta. Esta puede indicarse:

  1. en la cabecera HTTP Content-Type: text/html; charset=ISO-8859-1;
  2. en <meta charset="..."> o <meta http-equiv="Content-Type"> dentro del HTML;
  3. en ningún sitio (entonces hay que adivinarla).

Si lee el cuerpo como UTF-8 pero el sitio usa ISO-8859-1 (un clásico de la web hispana antigua), obtendrá basura.

Regla: trabaje con bytes y determine la codificación de forma explícita

jsoup lo resuelve casi de forma automática si se le pasan bytes o un InputStream en lugar de una cadena ya formada — él mismo lee el charset de la cabecera o del <meta>:

java
// Correcto: jsoup detecta la codificación por meta/charset
byte[] bytes = response.body();  // desde HttpClient/OkHttp como byte[]
InputStream in = new ByteArrayInputStream(bytes);
Document doc = Jsoup.parse(in, null, "https://example.com");
//                              ^^^^ null = detección automática del charset

Si el charset se conoce de antemano, indíquelo de forma explícita:

java
Document doc = Jsoup.parse(in, "ISO-8859-1", "https://example.com");

Decodificación manual

Cuando no analice con jsoup, decodifique los bytes usted mismo:

java
import java.nio.charset.Charset;

// si conoce la codificación
String html = new String(bytes, Charset.forName("ISO-8859-1"));

// para UTF-8
String html2 = new String(bytes, StandardCharsets.UTF_8);

Detección automática de la codificación

Si la codificación no se indica en ninguna parte, ayudan las bibliotecas detectoras: juniversalchardet (port de universalchardet de Mozilla) o ICU4J (CharsetDetector).

java
// org.apache.tika:tika-core también sabe detectar el charset
import org.apache.tika.parser.txt.CharsetDetector;

CharsetDetector detector = new CharsetDetector();
detector.setText(bytes);
String charset = detector.detect().getName(); // p. ej. "ISO-8859-1"
String html = new String(bytes, charset);

Checklist de acentos y ñ

  • Nunca convierta la respuesta a cadena en UTF-8 «a ciegas».
  • Pase a jsoup un InputStream/byte[], no un String.
  • Guarde los archivos con una codificación explícita: Files.write(path, html.getBytes(StandardCharsets.UTF_8)).
  • En la consola también aparecen artefactos — fije -Dfile.encoding=UTF-8 y compruebe la codificación del terminal.

5. Estado de la respuesta y cabeceras

No conviene analizar el cuerpo sin más — compruebe primero que el servidor ha devuelto realmente la página y no una redirección, un captcha o un error.

java
HttpResponse<byte[]> resp = client.send(request, BodyHandlers.ofByteArray());

int status = resp.statusCode();
HttpHeaders headers = resp.headers();

// lectura de cabeceras concretas
String contentType = headers.firstValue("Content-Type").orElse("");
String server      = headers.firstValue("Server").orElse("");
long length        = headers.firstValueAsLong("Content-Length").orElse(-1);

// todas las cabeceras
headers.map().forEach((k, v) -> System.out.println(k + ": " + v));

Qué conviene vigilar:

Código Significado Reacción del scraper
200 OK analizamos
301/302 redirección seguimos Location (o activamos follow-redirects)
403 acceso denegado probablemente anti-bot — cambiamos UA/proxy
404 no encontrado marcamos la URL como muerta
429 demasiadas peticiones esperamos Retry-After, bajamos el ritmo
5xx error del servidor reintento con retardo exponencial

Cabeceras de respuesta útiles: Content-Type (tipo y codificación), Set-Cookie (véase la sección 6), Location (redirección), Retry-After (pausa ante 429/503), ETag/Last-Modified (para peticiones condicionales y rastreo incremental).

En OkHttp es análogo: response.code(), response.header("Content-Type"), response.headers().


Las cookies son necesarias para las sesiones, la autenticación, superar las «comprobaciones anti-bot» y conservar el estado entre peticiones.

6.1. HttpClient integrado + CookieManager

java
import java.net.*;

CookieManager cookieManager = new CookieManager();
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);

HttpClient client = HttpClient.newBuilder()
        .cookieHandler(cookieManager)   // las cookies ahora se guardan automáticamente
        .build();

// tras varias peticiones, inspeccionar el almacén:
CookieStore store = cookieManager.getCookieStore();
store.getCookies().forEach(c ->
        System.out.println(c.getName() + "=" + c.getValue()));

Ahora las cookies se envían solas entre peticiones — algo cómodo para el inicio de sesión: primero un POST con usuario y contraseña y, después, peticiones a las páginas protegidas con el mismo client.

6.2. OkHttp + CookieJar

java
// La vía más simple: un PersistentCookieJar ya hecho o un CookieJar propio en memoria
OkHttpClient client = new OkHttpClient.Builder()
        .cookieJar(new JavaNetCookieJar(cookieManager))
        .build();

6.3. jsoup: reenvío manual

jsoup guarda las cookies en el objeto Connection.Response y pueden reenviarse manualmente:

java
Connection.Response login = Jsoup.connect("https://site.com/login")
        .data("username", "user", "password", "pass")
        .method(Connection.Method.POST)
        .execute();

Map<String, String> cookies = login.cookies();

Document page = Jsoup.connect("https://site.com/profile")
        .cookies(cookies)      // pasamos la sesión
        .get();

Un escollo: en el scraping multihilo, una única sesión compartida puede «arrastrar» el estado entre hilos. Para cuentas o proxies distintos, cree almacenes de cookies independientes por cada hilo o worker.


7. HTTPS/SSL

La mayoría de los sitios usan HTTPS. Por lo general, el HttpClient integrado u OkHttp se encargan de todo: validan el certificado contra las CA raíz de confianza del JDK. Solo hace falta código adicional en dos casos.

7.1. Certificados autofirmados y «problemáticos»

A veces el sitio objetivo tiene un certificado autofirmado o caducado. Surge la tentación de desactivar la validación:

java
// ⚠️ PELIGROSO: desactiva toda la validación SSL — ¡solo para pruebas o redes de confianza!
TrustManager[] trustAll = new TrustManager[]{
    new X509TrustManager() {
        public void checkClientTrusted(X509Certificate[] c, String a) {}
        public void checkServerTrusted(X509Certificate[] c, String a) {}
        public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
    }
};
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAll, new SecureRandom());

HttpClient client = HttpClient.newBuilder()
        .sslContext(sc)
        .build();

Esto no debe hacerse en producción — abre la puerta a ataques MITM. Lo correcto es añadir el certificado concreto a un truststore personalizado:

bash
keytool -import -alias mysite -file mysite.crt -keystore custom.jks
java
KeyStore ks = KeyStore.getInstance("JKS");
try (InputStream in = Files.newInputStream(Path.of("custom.jks"))) {
    ks.load(in, "changeit".toCharArray());
}
TrustManagerFactory tmf =
        TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(ks);
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, tmf.getTrustManagers(), null);

7.2. Huella TLS (JA3): anti-bot avanzado

Las protecciones serias (Cloudflare y otras) distinguen a los clientes por su huella TLS (JA3/JA4): el conjunto de cifrados y extensiones del ClientHello. La del cliente Java estándar «no parece de navegador», así que resulta fácil identificar al bot. Falsificarla por completo con un JDK puro es complicado; en la práctica se recurre a envoltorios del tipo curl-impersonate o utls o se enruta el tráfico a través de un navegador real (sección 11). Es un tema de nicho, pero importante para los sitios «duros».


8. Proxies

Los proxies sirven para repartir la carga entre varias IP, sortear restricciones geográficas y reducir el riesgo de bloqueo por IP en el scraping masivo. Tipos: datacenter (baratos, fáciles de detectar), residenciales (caros, parecen un usuario real) y móviles (los más «limpios»).

8.1. HttpClient integrado

java
import java.net.*;

HttpClient client = HttpClient.newBuilder()
        .proxy(ProxySelector.of(new InetSocketAddress("proxy.host", 8080)))
        .build();

Con autenticación de proxy por usuario y contraseña:

java
Authenticator auth = new Authenticator() {
    @Override protected PasswordAuthentication getPasswordAuthentication() {
        return new PasswordAuthentication("user", "pass".toCharArray());
    }
};
HttpClient client = HttpClient.newBuilder()
        .proxy(ProxySelector.of(new InetSocketAddress("proxy.host", 8080)))
        .authenticator(auth)
        .build();

Un matiz del JDK: para el uso de proxy puede hacer falta la propiedad de sistema -Djdk.http.auth.tunneling.disabledSchemes=""; de lo contrario, la autenticación Basic sobre el túnel CONNECT falla en silencio.

8.2. OkHttp (a menudo más cómodo para proxies)

java
Proxy proxy = new Proxy(Proxy.Type.HTTP,
        new InetSocketAddress("proxy.host", 8080));

OkHttpClient client = new OkHttpClient.Builder()
        .proxy(proxy)
        .proxyAuthenticator((route, response) -> {
            String credential = Credentials.basic("user", "pass");
            return response.request().newBuilder()
                    .header("Proxy-Authorization", credential)
                    .build();
        })
        .build();

OkHttp también admite Proxy.Type.SOCKS — algo que necesitaremos para TOR.

8.3. Rotación de proxies

Para el scraping masivo se mantiene un pool de proxies y se van alternando de forma cíclica o cuando llega un bloqueo. La rotación más simple:

java
List<Proxy> pool = loadProxies();
AtomicInteger idx = new AtomicInteger();

Proxy next() {
    return pool.get(idx.getAndIncrement() % pool.size());
}

En la práctica se añaden: comprobación de «vitalidad» de los proxies, una lista negra de los «quemados», la asociación de la sesión o las cookies a un proxy concreto y, para cada proxy, su propio OkHttpClient (son baratos si comparten el pool de conexiones, pero conviene reutilizarlos).


9. Scraping a través de TOR

TOR aporta anonimato y un cambio de IP gratuito, pero es lento y muchos sitios bloquean sus nodos de salida. Sirve para tareas puntuales, no para un scraping de alta velocidad.

9.1. Preparación

  1. Instale TOR (el paquete tor o Tor Browser).
  2. TOR levanta un proxy SOCKS5 local, normalmente en 127.0.0.1:9050 (en Tor Browser, 9150).
  3. Para cambiar de circuito (nueva IP) se usa el ControlPort (9051) con el comando NEWNYM.

Un torrc mínimo:

code
SocksPort 9050
ControlPort 9051
CookieAuthentication 1

9.2. Peticiones a través de TOR (SOCKS5)

java
Proxy torProxy = new Proxy(Proxy.Type.SOCKS,
        new InetSocketAddress("127.0.0.1", 9050));

OkHttpClient client = new OkHttpClient.Builder()
        .proxy(torProxy)
        .build();

Request req = new Request.Builder()
        .url("https://check.torproject.org/api/ip")  // comprobación de que estamos en TOR
        .build();

try (Response resp = client.newCall(req).execute()) {
    System.out.println(resp.body().string()); // {"IsTor":true,...}
}

Importante: con SOCKS5, la resolución DNS debe ir a través del proxy (DNS remoto); de lo contrario, la consulta DNS «se filtra» desde su IP real. OkHttp con Proxy.Type.SOCKS lo tiene en cuenta; si usa el JDK puro, vigile las fugas de DNS.

9.3. Cambio de IP con el comando NEWNYM

java
import java.io.*;
import java.net.Socket;

void newTorIdentity() throws IOException {
    try (Socket s = new Socket("127.0.0.1", 9051);
         PrintWriter out = new PrintWriter(s.getOutputStream(), true);
         BufferedReader in = new BufferedReader(
                 new InputStreamReader(s.getInputStream()))) {

        out.println("AUTHENTICATE \"\"");   // o la contraseña, si está definida
        in.readLine();                       // esperamos 250 OK
        out.println("SIGNAL NEWNYM");        // nuevo circuito
        in.readLine();
    }
}

Tras NEWNYM conviene esperar un poco (TOR tarda unos segundos en construir el nuevo circuito) y no invocar el comando con demasiada frecuencia.


10. Multithreading

El scraping es una tarea limitada por E/S (I/O-bound): la mayor parte del tiempo esperamos a la red, así que la paralelización aporta una mejora enorme. Lo esencial es no «tumbar» el sitio objetivo ni ganarse un bloqueo.

10.1. ExecutorService: el clásico

java
import java.util.concurrent.*;

ExecutorService pool = Executors.newFixedThreadPool(10);
List<Future<String>> futures = new ArrayList<>();

for (String url : urls) {
    futures.add(pool.submit(() -> fetchAndParse(url)));
}

for (Future<String> f : futures) {
    try {
        String result = f.get(30, TimeUnit.SECONDS);
        // guardar el resultado
    } catch (Exception e) {
        // log + reintento/descartar
    }
}
pool.shutdown();

10.2. Hilos virtuales (Java 21+): ideales para E/S

Los hilos virtuales permiten miles de hilos «baratos» que se quedan a la espera de la red — justo lo que necesita el scraping:

java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (String url : urls) {
        executor.submit(() -> fetchAndParse(url));
    }
} // close() espera a que terminen todas las tareas

10.3. CompletableFuture + HttpClient asíncrono

java
List<CompletableFuture<HttpResponse<byte[]>>> calls = urls.stream()
        .map(u -> client.sendAsync(buildRequest(u), BodyHandlers.ofByteArray()))
        .toList();

CompletableFuture.allOf(calls.toArray(new CompletableFuture[0])).join();

10.4. Qué hay que tener en cuenta sí o sí

  • Límite de concurrencia por dominio. 200 hilos contra un mismo sitio equivalen a un DoS y a un bloqueo inmediato. Limítelos, por ejemplo con un Semaphore por dominio.
  • Throttling. Introduzca retardos y jitter entre peticiones (véase la sección 12).
  • Seguridad entre hilos. Es mejor no compartir un Document de jsoup entre hilos; para colecciones compartidas use ConcurrentHashMap o ConcurrentLinkedQueue, y para contadores, AtomicInteger/LongAdder.
  • Backpressure. No vuelque un millón de tareas en la cola de golpe — use una cola acotada (ArrayBlockingQueue) y CallerRunsPolicy para que el productor se frene.
  • Un único OkHttpClient/HttpClient para toda la aplicación. Son seguros entre hilos y mantienen el pool de conexiones; crear uno por cada petición es un antipatrón.

11. Renderizado de JavaScript

jsoup solo ve el HTML que llegó en la respuesta. Si el sitio es una SPA (React/Vue/Angular) y los datos se pintan con JavaScript en el navegador, no estarán en el HTML «en bruto». Opciones:

  1. Encontrar la API interna (véase 3.3) — casi siempre la mejor vía: más rápida, más estable y sin navegador.
  2. Selenium WebDriver — control de un navegador real (Chrome/Firefox mediante WebDriver). Pesado y lento, pero ve todo lo que ve el usuario.
  3. Playwright for Java — la alternativa moderna a Selenium, de Microsoft: más rápida, con una API más cómoda y buen manejo del contenido dinámico y las esperas.
  4. HtmlUnit — un navegador ligero integrado en Java; ejecuta JS de forma limitada, pero sin binarios externos.
java
// Selenium (Chrome headless)
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new");
WebDriver driver = new ChromeDriver(options);
driver.get("https://spa-site.com");

// esperar a que aparezca el elemento
new WebDriverWait(driver, Duration.ofSeconds(10))
    .until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".item")));

String renderedHtml = driver.getPageSource();
// después se puede pasar a jsoup para un análisis cómodo:
Document doc = Jsoup.parse(renderedHtml);
driver.quit();

Los motores de navegador resuelven de paso parte de los problemas anti-bot (huella TLS correcta, ejecución de retos JS), pero lo pagan en recursos: una sola instancia de Chrome consume cientos de megabytes de RAM, por lo que escalarlas sale caro.


12. Protección anti-bot

Cuanto más masivo es el scraping, mayor es la probabilidad de bloqueo. «Cortesía» básica y camuflaje:

  • User-Agent. Use un UA realista de un navegador de verdad; para tareas masivas, rote una lista de UA. El UA por defecto de Java es una señal de alarma para el sitio.
  • Juego completo de cabeceras. Un navegador real envía Accept, Accept-Language, Accept-Encoding, Referer, Sec-Fetch-*. Imítelas.
  • Retardos y jitter. Una pausa aleatoria (por ejemplo, de 1 a 5 s) entre peticiones en lugar de una «ametralladora» uniforme.
  • Reintentos con retardo exponencial ante 429/5xx, respetando Retry-After.
  • Rotación de IP (sección 8) — la herramienta principal contra los bloqueos por IP.
  • Captcha. Si aparece un captcha, las opciones son: bajar el ritmo, cambiar de IP o usar servicios de resolución (2Captcha/Anti-Captcha), aunque esto ya es una zona gris; valore los riesgos.
  • Cloudflare y retos JS. A menudo solo se superan con un navegador real (sección 11) o con bibliotecas especializadas para sortearlos.

Esqueleto de reintentos:

java
int attempts = 0;
while (attempts < MAX_RETRIES) {
    HttpResponse<byte[]> r = client.send(req, BodyHandlers.ofByteArray());
    int code = r.statusCode();
    if (code == 200) return r.body();
    if (code == 429 || code >= 500) {
        long backoff = (long) (Math.pow(2, attempts) * 1000)
                + ThreadLocalRandom.current().nextInt(500); // jitter
        Thread.sleep(backoff);
        attempts++;
    } else {
        break; // 403/404 — el reintento no ayudará
    }
}

13. Almacenamiento de URLs y colas

Un crawler es, en esencia, el recorrido de un grafo de enlaces. Hacen falta dos estructuras:

  • La cola (frontier) — las URL que aún quedan por recorrer.
  • El conjunto de visitadas (visited) — para no recorrer una misma URL dos veces ni entrar en bucle.

En memoria (para tareas pequeñas)

java
Queue<String> frontier = new ConcurrentLinkedQueue<>();
Set<String> visited = ConcurrentHashMap.newKeySet();

if (visited.add(url)) {       // add devuelve false si ya estaba
    frontier.offer(url);
}

Parseo de URL

Antes de normalizar o filtrar un enlace hay que descomponerlo en sus partes (esquema, host, puerto, ruta, query, fragmento). En Java existe para ello el java.net.URI estándar:

java
import java.net.URI;

URI uri = URI.create("https://Site.com:443/catalog/item?id=7&ref=a#section");

String scheme   = uri.getScheme();    // https
String host     = uri.getHost();      // Site.com
int    port     = uri.getPort();      // 443
String path     = uri.getPath();      // /catalog/item
String query    = uri.getQuery();     // id=7&ref=a
String fragment = uri.getFragment();  // section

// resolución de un enlace relativo a absoluto:
URI abs = uri.resolve("../other");    // https://Site.com:443/other

jsoup hace lo mismo de forma automática con los enlaces de la página — link.absUrl("href") (véase la sección 3) devuelve ya la URL absoluta apoyándose en el baseUri. La descomposición en componentes con URI sirve precisamente para la posterior normalización.

Los mismos principios de análisis de URL se aplican en otros stacks — véase el artículo aparte «Parseo de URL».

Qué es importante

  • Normalización de URL antes de la deduplicación (apoyándose en Parseo de URL): eliminar los anclajes #..., ordenar los parámetros de la query, unificar las mayúsculas del host y decidir sobre http/https y la barra final. De lo contrario, site.com/a y site.com/a/ se contarán como distintas.
  • Deduplicación con grandes volúmenes. Mantener cientos de millones de URL en un HashSet es inviable por memoria — se recurre a un filtro de Bloom (BloomFilter de Guava) o se externaliza el conjunto a Redis o a una base de datos.
  • Persistencia. En rastreos largos, la cola y el conjunto visited deben sobrevivir a un reinicio: Redis (List/Set), Kafka/RabbitMQ como cola de tareas, una base de datos relacional o un almacén embebido.
  • Prioridades. A veces hace falta una PriorityBlockingQueue (primero las secciones importantes) o un límite de profundidad de rastreo.
  • Distribución. Al escalar a muchas máquinas, la cola y la deduplicación se llevan a un broker externo o a Redis para que los workers no dupliquen el trabajo.

Arquitectura típica: un productor saca URL del frontier → un pool de workers descarga y analiza → los enlaces extraídos se filtran, se normalizan, se comprueban contra visited y se devuelven al frontier; los datos se escriben en el almacén.


14. Frameworks listos para usar

Para no escribir un crawler desde cero, existen soluciones ya hechas:

  • crawler4j — un crawler multihilo sencillo en Java, de arranque rápido.
  • webmagic — un framework flexible (inspirado en Scrapy) con pipelines y planificador.
  • Apache Nutch — un crawler pesado, escalable y de nivel industrial (a menudo junto con Hadoop/Solr).
  • StormCrawler — un crawler distribuido sobre Apache Storm para el procesamiento en flujo.

Para la mayoría de las tareas prácticas, la combinación «OkHttp/HttpClient + jsoup + un pool de hilos propio + cola en Redis» es más que suficiente y resulta más transparente que la «magia» de los frameworks.


15. Ventajas e inconvenientes

Ventajas de la implementación en Java

  • Rendimiento y multithreading. La JVM soporta muy bien una concurrencia alta; los hilos virtuales (Java 21+) llevan el scraping I/O-bound a otro nivel.
  • Ecosistema maduro. jsoup, OkHttp, Apache HttpClient, Selenium/Playwright, Jackson — todo estable y bien documentado.
  • Fiabilidad y seguridad de tipos. El tipado estricto detecta errores en tiempo de compilación; cómodo para crawlers grandes y de larga vida.
  • Integración con el stack empresarial. Fácil de incrustar en un servicio Spring y de conectar con Kafka, bases de datos y monitorización.
  • Multiplataforma. Un único JAR se ejecuta en cualquier sitio.

Inconvenientes

  • Verbosidad. Hay más código que en Python; un prototipo rápido con requests + BeautifulSoup se escribe en menos líneas.
  • Menos herramientas específicas de scraping. En Python, el ecosistema de scraping (Scrapy y demás) es más rico y popular.
  • Los sitios con JavaScript son un quebradero de cabeza. Sin navegador (Selenium/Playwright) no se captura el contenido dinámico, y los navegadores son pesados y voraces.
  • Carrera armamentística anti-bot. La huella TLS, el análisis de comportamiento y los captchas exigen mantenimiento constante; el cliente Java estándar se detecta con facilidad.
  • Codificaciones. Los acentos, la ñ y ISO-8859-1 exigen cuidado (sección 4) — una fuente habitual de errores.
  • Fragilidad. Cualquier scraper se rompe cuando cambia el maquetado del sitio; hay que mantener los selectores.

Cuándo Java es una buena elección

Un crawler grande, de larga vida y con mucha carga; un equipo que ya trabaja con un stack Java; la necesidad de un multithreading intensivo y de integración con la infraestructura. Para el «extraer una tablita de una sola página» puntual, Python suele ser más rápido en tiempo de desarrollo.


Las versiones de las bibliotecas corresponden a 2026 (jsoup 1.22.2). Antes de usarlas, contraste las versiones actuales en Maven Central — la API puede haber cambiado.