Saltar al contenido
Volver al blog

Web scraping en Java: cómo crear un scraper fiable en Java

Raluca PenciucÚltima actualización el 32 min read
Web scraping en Java: cómo crear un scraper fiable en Java
En resumen: Empieza con HttpClient y Jsoup cuando los datos estén presentes en el HTML devuelto; llama a una fuente JSON expuesta cuando sea oportuno; y añade renderizado o Selenium solo para el estado dependiente del navegador. Este tutorial de web scraping en Java amplía un código base de Java 21 con paginación, concurrencia limitada, reintentos, sesiones, validación y salida duradera.

El web scraping consiste en la extracción programática de información de sitios web para que los datos resultantes puedan almacenarse, validarse y analizarse. Una pila práctica de Java para web scraping suele comenzar con la función integrada en Java 21 HttpClient para las solicitudes y Jsoup para el análisis de HTML, y luego añade otras herramientas solo cuando la página las requiere.

Lo difícil rara vez es seleccionar un elemento de una página. Los problemas de fiabilidad surgen cuando la paginación entra en bucle, los trabajadores concurrentes saturan un servicio, los fallos transitorios se confunden con errores permanentes, JavaScript oculta la fuente de datos real, las solicitudes autenticadas pierden sus cookies o un selector devuelve silenciosamente cero registros.

Esta guía describe la creación de un pequeño rastreador web en Java a lo largo de esas etapas. En primer lugar, clasificarás la página como HTML del servidor, JSON, contenido renderizado o interacción con el navegador. A continuación, crearás un diseño tipado de «obtención-análisis-rastreo-salida», seguirás la paginación sin descargas duplicadas, compararás ejecutores fijos con subprocesos virtuales, implementarás reintentos que tengan en cuenta el estado, conservarás las sesiones autorizadas y escribirás resultados validados.

Los ejemplos utilizan un sitio web público de prácticas y límites de seguridad deliberados. Antes de utilizar los mismos patrones en otros contextos, confirma los requisitos de acceso, las versiones actuales de las dependencias, los selectores de destino y las políticas que se aplican a tu recopilación específica.

Elige el enfoque de scraping en Java más sencillo

La mejor pila es la menos compleja que devuelva los datos de forma fiable. Empieza solicitando la URL una vez, inspecciona el cuerpo de la respuesta y compáralo con lo que muestra el navegador. Si los valores ya existen en la respuesta del servidor, HttpClient más Jsoup suele ser suficiente. Si la página expone una solicitud JSON, llama a ese punto final en su lugar. Añade renderización o automatización del navegador solo cuando los datos dependan realmente de JavaScript o de la interacción del usuario.

Esta regla de decisión permite que un proyecto de scraping web en Java sea pequeño, fácil de probar y económico de mantener.

Clasifica la página: HTML, JSON, DOM renderizado o interacción

Antes de elegir una biblioteca, clasifica cómo llegan los datos de destino al navegador:

Tipo de página

Lo que observas

Enfoque adecuado más sencillo

HTML devuelto por el servidor

El título, el precio, las filas o los enlaces aparecen en «Ver código fuente» o en la respuesta HTTP sin procesar

HttpClient se recarga la página, Jsoup la analiza

JSON expuesto

DevTools muestra una respuesta Fetch/XHR que contiene los registros

Reproduce la solicitud HTTP documentada o permitida y analiza el JSON

DOM renderizado

La respuesta inicial carece de los datos, pero JavaScript los inserta tras la carga

Utiliza un renderizador gestionado o un navegador y, a continuación, pasa el HTML resultante al mismo analizador

Interacción exclusiva del navegador

Los datos solo aparecen tras hacer clic, desplazarse, enviar un formulario o realizar una transición de estado del lado del cliente

Utiliza Selenium u otra herramienta de automatización del navegador

No deduzcas el tipo de página a partir de su complejidad visual. Una interfaz muy interactiva puede leer datos de un sencillo punto final JSON, mientras que una tabla sencilla puede generarse en el navegador.

Utiliza esta secuencia de comprobación:

  1. Envía una solicitud GET normal y guarda el estado, el tipo de contenido y la primera parte del cuerpo.
  2. Busca en el HTML devuelto un valor que puedas ver en pantalla.
  3. Abre DevTools, recarga la página y filtra el panel «Red» por «Fetch/XHR».
  4. Inspecciona las cargas útiles de las respuestas, los métodos de solicitud, los parámetros de consulta, los encabezados y los tokens de paginación.
  5. Determina si el acceso necesario es una solicitud HTML directa, una solicitud JSON, una salida renderizada o una interacción con el navegador.

Un punto final JSON no es automáticamente público ni de acceso libre. Mantén la autenticación necesaria, respeta las condiciones del sitio y evita copiar ciegamente tokens de corta duración. Del mismo modo, la automatización del navegador no es una solución universal para los controles de acceso. Simplemente proporciona a tu código Java un entorno de ejecución real de navegador.

Para páginas estáticas, el web scraping con Jsoup funciona bien porque Jsoup convierte una cadena HTML en un documento consultable. Para un análisis más profundo del análisis sintáctico de HTML en Java con Jsoup, la capa de análisis sintáctico puede estudiarse independientemente de la capa de red. Esa separación cobra importancia más adelante, cuando cambia el mecanismo de obtención de datos.

Planifica un diseño sencillo de «obtención-análisis-rastreo-salida»

Evita incluir llamadas HTTP, selectores, paginación, reintentos y escritura de archivos en un solo main método. Basta con un diseño sencillo con cuatro responsabilidades:

fetch:  URI -> status, headers, HTML
parse:  HTML + base URI -> typed records and next link
crawl:  decide which URI to visit next and enforce limits
output: validate, deduplicate, and persist records

Representa el límite de la recuperación mediante una interfaz:

public interface PageFetcher {
    FetchResult fetch(URI uri) throws IOException, InterruptedException;
}

public record FetchResult(
        URI uri,
        int statusCode,
        HttpHeaders headers,
        String body) {}

El analizador no debe saber nada sobre proxies, cookies, reintentos o controladores de navegador. Recibe texto y un URI base, y luego devuelve registros de dominio. El rastreador se encarga de los límites de páginas, las URL visitadas y el orden de las solicitudes. La capa de salida se encarga de la normalización y el almacenamiento.

Mantén los metadatos de transporte disponibles incluso cuando el analizador no los utilice. Los códigos de estado, los encabezados de respuesta, las ubicaciones de redireccionamiento finales y el tiempo transcurrido son útiles para tomar decisiones sobre reintentos y realizar diagnósticos. Pasar esos detalles a través de FetchResult evita que el analizador quede acoplado a un cliente HTTP concreto.

Esto no es un marco de trabajo. Es simplemente la estructura mínima necesaria para permitir que un rastreador web en Java evolucione sin tener que reescribir los selectores cada vez que cambia la capa de solicitud. Además, crea puntos de prueba evidentes: el HTML guardado permite probar el análisis, un PageFetcher puede probar la lógica de rastreo, y los archivos temporales pueden probar la salida.

Crea el núcleo del rastreador Java 21 para un proyecto de rastreo web en Java

Ahora convierte el modelo de decisión en un proyecto funcional. La primera versión recuperará una página de prácticas autorizadas, analizará las fichas de productos, resolverá los enlaces relativos e imprimirá los registros tipificados. Las secciones posteriores amplían el mismo recuperador y analizador en lugar de sustituirlos.

Crea el proyecto y añade HttpClient y Jsoup

Utiliza un JDK completo de Java 21, no una instalación solo de tiempo de ejecución:

java -version
javac -version

HttpClient forma parte del JDK, por lo que Jsoup es la única dependencia necesaria para el ejemplo estático. El material fuente proporcionado fija la versión de Jsoup 1.21.2, pero esa versión es sensible al tiempo. Comprueba la versión actual compatible con Java 21 antes de la publicación y vuelve a ejecutar la compilación.

<!-- pom.xml -->
<properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <jsoup.version>1.21.2</jsoup.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.jsoup</groupId>
        <artifactId>jsoup</artifactId>
        <version>${jsoup.version}</version>
    </dependency>
</dependencies>

El equivalente compacto en Gradle es:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation 'org.jsoup:jsoup:1.21.2'
}

Elige una herramienta de compilación y mantén la coherencia. Maven hace explícito el gráfico de dependencias en XML; Gradle es más conciso, pero introduce su propio lenguaje de compilación (DSL). Ninguno de los dos cambia la arquitectura del rastreador.

Una estructura de código fuente mínima mantiene las responsabilidades a la vista:

src/main/java/example/
  PageFetcher.java
  FetchResult.java
  HttpPageFetcher.java
  Book.java
  BookParser.java
  StaticBookScraper.java

El tutorial utiliza un único archivo, tal y como se muestra a continuación, para que puedas ejecutarlo rápidamente. Una vez que la primera solicitud funcione, divide los tipos anidados en esos archivos sin modificar sus contratos públicos. Esto facilita la prueba aislada posterior de las clases de rastreo, reintento y salida.

Modela los registros y elige selectores CSS resilientes

Modela cada fila extraída como un registro de Java en lugar de pasar listas paralelas de cadenas:

public record Book(
        String title,
        String price,
        URI detailUrl,
        URI sourcePage) {}

Un selector CSS debe describir una estructura estable, no una posición incidental. Es preferible utilizar un contenedor «product» repetido junto con descendientes semánticos, como article.product_pod, h3 a, y .price_color. Evita los selectores creados a partir de nombres de clases generados o cadenas largas div:nth-child(...) , a menos que el marcado no te ofrezca una opción mejor. Una hoja de referencia de selectores CSS resulta útil cuando necesitas selectores de atributos, hijos, hermanos o compuestos.

Trata cada coincidencia interna como opcional. selectFirst() devuelve null cuando cambia el marcado, así que lee el elemento solo después de haberlo comprobado. Normaliza también el texto en los límites y conserva la página de origen para poder rastrearla.

static List<Book> parseBooks(String html, URI pageUri) {
    Document document = Jsoup.parse(html, pageUri.toString());
    List<Book> books = new ArrayList<>();

    for (Element card : document.select("article.product_pod")) {
        Element link = card.selectFirst("h3 a");
        Element price = card.selectFirst(".price_color");

        if (link == null || price == null) {
            continue;
        }

        String title = link.attr("title").trim();
        if (title.isBlank()) {
            title = link.text().trim();
        }

        String absoluteUrl = link.absUrl("href");
        String cleanPrice = price.text().trim();

        if (title.isBlank() || absoluteUrl.isBlank() || cleanPrice.isBlank()) {
            continue;
        }

        books.add(new Book(
                title,
                cleanPrice,
                URI.create(absoluteUrl),
                pageUri));
    }

    return List.copyOf(books);
}

El URI base pasado a Jsoup.parse() es lo que hace que absUrl("href") útil. Resuelve enlaces como catalogue/book.html en relación con la página que los contenía. Esto es más seguro que concatenar cadenas, especialmente cuando la paginación lleva al rastreador a un subdirectorio.

La resiliencia de un selector no es lo mismo que su vaguedad. Un selector como a puede sobrevivir a un rediseño, pero devolver enlaces de navegación no relacionados. Selecciona el contenedor estable más específico, extrae solo los campos que le pertenecen y cuenta los campos obligatorios que faltan. Durante el desarrollo, guarda un pequeño modelo HTML válido y prueba el analizador con él. Así se distingue una regresión en el marcado de un problema de red.

Ejecuta un rastreador completo de páginas estáticas

El siguiente programa de un solo archivo es un punto de partida compilable para el scraping web con Java HttpClient. Mantiene separadas las responsabilidades de obtención y análisis, comprueba el estado y el tipo de contenido, y limita tanto el tiempo de conexión como el tiempo por solicitud.

package example;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;

public final class StaticBookScraper {
    record FetchResult(
            URI uri,
            int statusCode,
            HttpHeaders headers,
            String body) {}

    record Book(
            String title,
            String price,
            URI detailUrl,
            URI sourcePage) {}

    interface PageFetcher {
        FetchResult fetch(URI uri)
                throws IOException, InterruptedException;
    }

    static final class HttpPageFetcher implements PageFetcher {
        private final HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(20))
                .followRedirects(HttpClient.Redirect.NORMAL)
                .build();

        @Override
        public FetchResult fetch(URI uri)
                throws IOException, InterruptedException {
            HttpRequest request = HttpRequest.newBuilder(uri)
                    .timeout(Duration.ofSeconds(60))
                    .header(
                            "User-Agent",
                            "Java21TutorialScraper/1.0 (+contact@example.com)")
                    .GET()
                    .build();

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

            return new FetchResult(
                    response.uri(),
                    response.statusCode(),
                    response.headers(),
                    response.body());
        }
    }

    static List<Book> parseBooks(String html, URI pageUri) {
        Document document = Jsoup.parse(html, pageUri.toString());
        List<Book> books = new ArrayList<>();

        for (Element card : document.select("article.product_pod")) {
            Element link = card.selectFirst("h3 a");
            Element price = card.selectFirst(".price_color");

            if (link == null || price == null) {
                continue;
            }

            String title = link.attr("title").trim();
            if (title.isBlank()) {
                title = link.text().trim();
            }

            String detailUrl = link.absUrl("href");
            String cleanPrice = price.text().trim();

            if (!title.isBlank()
                    && !detailUrl.isBlank()
                    && !cleanPrice.isBlank()) {
                books.add(new Book(
                        title,
                        cleanPrice,
                        URI.create(detailUrl),
                        pageUri));
            }
        }

        return List.copyOf(books);
    }

    public static void main(String[] args) throws Exception {
        URI start = URI.create("https://books.toscrape.com/");
        PageFetcher fetcher = new HttpPageFetcher();

        FetchResult response = fetcher.fetch(start);
        if (response.statusCode() < 200
                || response.statusCode() >= 300) {
            throw new IOException(
                    "Unexpected HTTP status "
                            + response.statusCode()
                            + " for "
                            + response.uri());
        }

        String contentType = response.headers()
                .firstValue("Content-Type")
                .orElse("");
        if (!contentType.toLowerCase().contains("text/html")) {
            throw new IOException(
                    "Expected HTML but received " + contentType);
        }

        List<Book> books = parseBooks(
                response.body(),
                response.uri());

        System.out.printf(
                "Fetched %s, extracted %d records%n",
                response.uri(),
                books.size());

        books.stream()
                .limit(3)
                .forEach(book -> System.out.printf(
                        "%s | %s | %s%n",
                        book.title(),
                        book.price(),
                        book.detailUrl()));
    }
}

Compílalo mediante Maven o Gradle para que Jsoup esté en la ruta de clases. La salida debería indicar la URL recuperada, el recuento de datos extraídos y hasta tres registros. No codifiques de forma fija un recuento esperado de páginas ni títulos de muestra, ya que el contenido y el marcado del sitio de prueba pueden cambiar.

Hay varios detalles que son intencionados. Se reutiliza una HttpClient ejemplo se reutiliza porque los clientes gestionan las conexiones y la configuración. La solicitud tiene un plazo limitado, las redirecciones se gestionan de forma explícita y la URI de la respuesta final se convierte en la URI base del analizador. El programa comprueba el rango 2xx en lugar de solo 200, y a continuación verifica que la respuesta se asemeje a HTML antes de aplicar los selectores.

El ejemplo lanza una excepción ante un fallo de transporte o de protocolo, ya que un comando de una sola página debería fallar de forma evidente. Un rastreador necesita un comportamiento más refinado: clasificar el fallo, reintentar solo los casos transitorios, registrar el resultado final y continuar cuando la política lo permita. Añadiremos ese comportamiento más adelante sin modificar parseBooks().

El User-Agent del ejemplo identifica la aplicación en lugar de hacerse pasar por un navegador específico. Algunos sitios pueden requerir encabezados adicionales, pero los encabezados deben reflejar la solicitud que realmente estás realizando. Si un adaptador de recuperación posterior necesita un secreto, léelo con System.getenv() y devuelve un error claro cuando no esté presente.

Convierte la extracción en un rastreador controlado

Un extractor de una sola página se convierte en un rastreador web en Java cuando elige y sigue URL adicionales. Ese cambio introduce estado y reglas de terminación. Mantén el recuperador y el analizador existentes, y luego añade un coordinador de rastreo que gestione la página actual, el conjunto de páginas visitadas, el límite de páginas y el cuerpo de la respuesta almacenada en caché.

Sigue la paginación y resuelve las URL relativas

El código Java de scraping web con paginación debe tener más de una condición de parada. Detente cuando desaparezca el siguiente enlace, cuando la siguiente URL no sea válida, cuando se repita una URL, cuando falle una respuesta o cuando se alcance el límite de páginas configurado. El límite te protege del marcado defectuoso y del recorrido accidental por todo el sitio.

Añade un tipo de resultado y un método de rastreo en torno al PageFetcher y parseBooks() :

record CrawlResult(
        List<Book> books,
        Map<URI, String> htmlByPage) {}

static CrawlResult crawlPages(
        URI start,
        int maxPages,
        PageFetcher fetcher)
        throws IOException, InterruptedException {

    if (maxPages < 1) {
        throw new IllegalArgumentException(
                "maxPages must be positive");
    }

    Set<URI> visited = new LinkedHashSet<>();
    Map<URI, String> cache = new LinkedHashMap<>();
    List<Book> books = new ArrayList<>();

    URI current = normalize(start);

    while (current != null && cache.size() < maxPages) {
        URI requested = normalize(current);
        if (!visited.add(requested)) {
            System.err.println(
                    "Stopping at repeated URL: " + requested);
            break;
        }

        FetchResult response = fetcher.fetch(requested);
        if (response.statusCode() < 200
                || response.statusCode() >= 300) {
            System.err.printf(
                    "Stopping after status %d for %s%n",
                    response.statusCode(),
                    requested);
            break;
        }

        URI finalUri = normalize(response.uri());
        if (cache.putIfAbsent(finalUri, response.body()) != null) {
            System.err.println(
                    "Stopping after redirect to cached URL: "
                            + finalUri);
            break;
        }
        visited.add(finalUri);

        books.addAll(parseBooks(
                response.body(),
                finalUri));

        Document document = Jsoup.parse(
                response.body(),
                finalUri.toString());

        Element next = document.selectFirst(
                "a[rel=next], li.next a");

        if (next == null) {
            current = null;
            continue;
        }

        String absoluteNext = next.absUrl("href");
        current = absoluteNext.isBlank()
                ? null
                : URI.create(absoluteNext);
    }

    return new CrawlResult(
            List.copyOf(books),
            Map.copyOf(cache));
}

static URI normalize(URI uri) {
    try {
        return new URI(
                uri.getScheme(),
                uri.getAuthority(),
                uri.getPath(),
                uri.getQuery(),
                null).normalize();
    } catch (URISyntaxException e) {
        throw new IllegalArgumentException(
                "Cannot normalize URI: " + uri,
                e);
    }
}

Este método analiza cada cuerpo recuperado dos veces: una para los registros y otra para el siguiente enlace. Esto no supone un gran coste en páginas pequeñas, pero puedes devolver tanto los registros como la siguiente URI con una sola llamada al analizador si el perfilado muestra un coste significativo. La optimización importante es evitar una segunda solicitud de red, no recortar prematuramente un pequeño análisis del DOM.

El grupo de selectores admite un enlace rel=next y la estructura del paginador del sitio de prácticas. Trátalo como un punto de partida, no como un selector universal. Examina el paginador real y mantén los selectores de navegación separados de los de productos para que sea fácil aislar cualquier cambio.

Llama al rastreador con una capacidad deliberadamente reducida durante el desarrollo:

CrawlResult result = crawlPages(
        URI.create("https://books.toscrape.com/"),
        3,
        new HttpPageFetcher());

System.out.printf(
        "Pages: %d, records: %d%n",
        result.htmlByPage().size(),
        result.books().size());

El número 3 es un límite de seguridad para esta ejecución, no un total estimado. Auméntalo solo tras confirmar que el selector «next-link» permanece dentro del host y la ruta previstos. En el código de producción, rechaza una URL siguiente fuera del sitio antes de recuperarla.

Para la paginación JSON basada en el cursor, la estructura del bucle es la misma. Sustituye next.absUrl("href") por la extracción del siguiente cursor o token, y detente cuando el token falte o se repita. No adivines los números de página cuando el servidor proporcione un valor de continuación explícito.

Evita los bucles y las descargas duplicadas con las URL visitadas y el HTML almacenado en caché

El conjunto de direcciones visitadas responde a una pregunta: ¿ya hemos intentado esta URL normalizada? La caché HTML responde a otra: ¿ya hemos descargado el contenido de esta URL final? Se necesitan ambas cosas porque las redirecciones pueden asignar varias URL solicitadas a una sola página, y los paginadores mal formados pueden apuntar hacia atrás.

La reutilización del HTML de descubrimiento es un diferenciador práctico para los rastreadores Java de web scraping. Un diseño ineficiente habitual recorre primero la paginación para recopilar URL y, a continuación, vuelve a recuperar cada URL durante la extracción en paralelo. El código anterior almacena cada respuesta satisfactoria a medida que descubre el siguiente enlace, de modo que una fase de análisis posterior puede consumir la caché directamente:

List<Book> reparsed = cache.entrySet().stream()
        .flatMap(entry -> parseBooks(
                entry.getValue(),
                entry.getKey()).stream())
        .toList();

En ese flujo no se produce ninguna llamada HTTP. Si más adelante paralelizas el análisis, paraleliza estos valores almacenados en caché o envíalos a tu propio ejecutor. No recurras silenciosamente a otra descarga.

La normalización de URL debe realizarse con moderación. Por lo general, es adecuado eliminar los fragmentos, ya que estos no se envían en las peticiones HTTP. En cambio, eliminar los parámetros de consulta no suele ser seguro, ya que los parámetros pueden identificar páginas, filtros o cursores. Define reglas de canonicalización para el destino en lugar de eliminar datos de forma global.

Una caché en memoria solo está limitada por maxPages y el tamaño de la página. Para un pequeño rastreo de tutoriales, eso es aceptable. Para una ejecución prolongada, almacena instantáneas de las respuestas en el disco o procésalas en lotes delimitados. El almacenamiento en caché debe reducir las solicitudes, no crear un montón ilimitado.

Ejecuta las solicitudes de forma concurrente sin perder el control

La concurrencia es una herramienta para aumentar el rendimiento, no una garantía de que el rastreo termine más rápido. El servicio remoto, la red, la CPU, la memoria y los límites de respuesta pueden convertirse en cuellos de botella. Empieza de forma secuencial, mide los resultados y, a continuación, introduce un pequeño límite explícito. Un diseño Java controlado para el scraping web en paralelo debería dejar claro cuál es el número máximo de solicitudes en curso.

Utiliza un ExecutorService fijo y colecciones concurrentes fáciles de programar

Un grupo fijo hace que el límite de solicitudes sea igual al número de trabajadores. Envía una URL independiente por tarea, conserva cada una de Futurey espera a que se complete, para que no se pierdan las tareas fallidas.

static List<Book> scrapeConcurrently(
        Collection<URI> urls,
        int maxInFlight,
        PageFetcher fetcher)
        throws InterruptedException, ExecutionException {

    ExecutorService executor =
            Executors.newFixedThreadPool(maxInFlight);

    Queue<Book> results = new ConcurrentLinkedQueue<>();
    Set<URI> claimed = ConcurrentHashMap.newKeySet();
    List<Future<?>> futures = new ArrayList<>();

    try {
        for (URI rawUrl : urls) {
            URI url = normalize(rawUrl);
            if (!claimed.add(url)) {
                continue;
            }

            futures.add(executor.submit(() -> {
                FetchResult response = fetcher.fetch(url);

                if (response.statusCode() >= 200
                        && response.statusCode() < 300) {
                    results.addAll(parseBooks(
                            response.body(),
                            response.uri()));
                } else {
                    System.err.printf(
                            "status=%d url=%s%n",
                            response.statusCode(),
                            url);
                }
                return null;
            }));
        }

        for (Future<?> future : futures) {
            future.get();
        }
    } finally {
        executor.shutdown();
        if (!executor.awaitTermination(
                30,
                TimeUnit.SECONDS)) {
            executor.shutdownNow();
        }
    }

    return List.copyOf(results);
}

ConcurrentLinkedQueue admite adiciones concurrentes frecuentes sin copiar toda la estructura subyacente. ConcurrentHashMap.newKeySet() proporciona un comportamiento atómico de «visto o no visto» a través de add(). Un ArrayList o HashSet no es seguro para escrituras simultáneas a menos que el acceso esté sincronizado. CopyOnWriteArrayList suele ser un mal receptor de resultados, ya que cada escritura copia su matriz subyacente.

La futures sigue siendo una ArrayList , ya que solo la escribe el hilo coordinador. La seguridad entre hilos debe aplicarse donde realmente se produce el uso compartido, no a todas las colecciones de forma refleja.

Es importante llamar a get() es importante. Si una tarea lanza una excepción, el coordinador recibe un ExecutionException en lugar de mostrar un mensaje optimista de éxito mientras las páginas fallaban silenciosamente. En un rastreador de larga duración, desenvuelve esa causa, registra la URL y decide si otras tareas pueden continuar. Si se interrumpe el coordinador, conserva el estado de la interrupción tras la limpieza en lugar de ignorarla.

No utilices parallelStream() simplemente para ahorrarte unas pocas líneas. Por defecto, utiliza el pool común «fork-join», que oculta el límite de solicitudes y puede entrar en conflicto con tareas no relacionadas en el mismo proceso. Un ejecutor explícito proporciona al rastreador una cola, un ciclo de vida y una capacidad observables.

Un grupo fijo es fácil de entender y sigue siendo una buena opción por defecto para un rastreador web en Java de tamaño modesto. Empieza con un límite bajo y medido, vigila la latencia y los códigos de estado, y ve aumentándolo poco a poco. El material de referencia proporcionado menciona pequeños rangos de demostración, pero no existe un número de subprocesos universalmente seguro. Los límites publicados por el sitio de destino y tu propio presupuesto de solicitudes son más importantes.

Utiliza subprocesos virtuales con un semáforo cuando la carga de trabajo lo justifique

Los hilos virtuales de Java 21 facilitan el escalado del código de E/S bloqueante sin necesidad de mantener un gran grupo de hilos de la plataforma. La JEP 444 de OpenJDK los describe como hilos ligeros gestionados por la JVM, destinados a dar soporte a aplicaciones concurrentes de alto rendimiento. No eliminan la necesidad de limitar la presión sobre un servicio remoto.

Utiliza un hilo virtual por tarea y un semáforo para el límite HTTP real:

static List<Book> scrapeWithVirtualThreads(
        Collection<URI> urls,
        int maxInFlight,
        PageFetcher fetcher)
        throws InterruptedException, ExecutionException {

    Semaphore permits = new Semaphore(maxInFlight);
    Queue<Book> results = new ConcurrentLinkedQueue<>();
    Set<URI> claimed = ConcurrentHashMap.newKeySet();
    List<Future<Void>> futures = new ArrayList<>();

    try (ExecutorService executor =
                 Executors.newVirtualThreadPerTaskExecutor()) {

        for (URI rawUrl : urls) {
            URI url = normalize(rawUrl);
            if (!claimed.add(url)) {
                continue;
            }

            futures.add(executor.submit(() -> {
                permits.acquire();
                try {
                    FetchResult response = fetcher.fetch(url);
                    if (response.statusCode() >= 200
                            && response.statusCode() < 300) {
                        results.addAll(parseBooks(
                                response.body(),
                                response.uri()));
                    }
                } finally {
                    permits.release();
                }
                return null;
            }));
        }

        for (Future<Void> future : futures) {
            future.get();
        }
    }

    return List.copyOf(results);
}

El número de subprocesos virtuales puede ser elevado, pero solo maxInFlight tarea puede entrar en el bloque de obtención. Adquiere el permiso inmediatamente antes de la operación restringida y libéralo en finally. No lo mantengas mientras se escriben registros en una base de datos o mientras se ejecutan tareas de CPU no relacionadas, a menos que esas operaciones compartan el mismo límite.

Si la paginación ya ha almacenado en caché el HTML, envía las tareas de análisis a través de la caché en lugar de llamar fetcher de nuevo. El análisis suele ser una tarea de CPU, así que realiza pruebas de rendimiento antes de asignarle miles de hilos virtuales. Los hilos virtuales están diseñados principalmente para que las operaciones de bloqueo sean más eficientes, no para acelerar todos los cálculos.

Elección

Prefiere esta opción cuando

Control principal

Grupo fijo

El número de tareas es moderado y los trabajadores con límites sencillos están bien definidos

Tamaño del grupo

Hilos virtuales

Tienes muchas tareas de E/S bloqueantes y quieres un código sencillo con un hilo por tarea

Semáforo u otro limitador explícito

Para el web scraping con hilos virtuales en Java, la simplicidad es la principal ventaja. Si un grupo fijo ya da abasto para la carga de trabajo, los hilos virtuales no son una mejora obligatoria. En ambas versiones, añade reintentos y límites por host antes de aumentar la concurrencia.

Haz que los fallos transitorios sean recuperables

Un rastreador fiable no trata de la misma manera todas las respuestas que no sean 2xx. Vuelve a intentar los fallos que sean plausiblemente temporales, como los límites de frecuencia, determinados errores del servidor, tiempos de espera agotados y restablecimientos de conexión. No reintentes ciegamente los fallos de autenticación, las solicitudes prohibidas, las páginas que faltan o las solicitudes mal formadas. Esos casos suelen requerir un cambio en las credenciales, los permisos, la URL o la construcción de la solicitud.

Implementa reintentos que tengan en cuenta el estado con «Retry-After» y «jitter»

La política de reintentos que se muestra a continuación envuelve el mismo PageFetcher contrato. Limita los intentos, respeta un Retry-After antes de calcular el retroceso local, añade jitter y registra los fallos definitivos. La semántica HTTP relevante se define en la sección «Retry-After» del RFC 9110.

final class RetryingHttpFetcher implements PageFetcher {
    record Failure(
            URI uri,
            int attempts,
            Integer statusCode,
            String message,
            String bodyPreview) {}

    private final HttpClient client;
    private final int maxAttempts;
    private final Duration baseDelay;
    private final Duration maxDelay;
    private final Queue<Failure> failures =
            new ConcurrentLinkedQueue<>();

    RetryingHttpFetcher(
            HttpClient client,
            int maxAttempts,
            Duration baseDelay,
            Duration maxDelay) {

        if (maxAttempts < 1) {
            throw new IllegalArgumentException(
                    "maxAttempts must be positive");
        }

        this.client = client;
        this.maxAttempts = maxAttempts;
        this.baseDelay = baseDelay;
        this.maxDelay = maxDelay;
    }

    @Override
    public FetchResult fetch(URI uri)
            throws IOException, InterruptedException {

        for (int attempt = 1;
             attempt <= maxAttempts;
             attempt++) {

            HttpRequest request = HttpRequest.newBuilder(uri)
                    .timeout(Duration.ofSeconds(60))
                    .header(
                            "User-Agent",
                            "Java21TutorialScraper/1.0 "
                                    + "(+contact@example.com)")
                    .GET()
                    .build();

            HttpResponse<String> response;
            try {
                response = client.send(
                        request,
                        HttpResponse.BodyHandlers.ofString());
            } catch (IOException error) {
                if (!isTransient(error)
                        || attempt == maxAttempts) {
                    recordFailure(
                            uri,
                            attempt,
                            null,
                            error.toString(),
                            "");
                    throw error;
                }

                Duration delay = backoff(attempt);
                logRetry(
                        uri,
                        null,
                        error.getClass().getSimpleName(),
                        attempt,
                        delay,
                        "");
                Thread.sleep(delay);
                continue;
            }

            int status = response.statusCode();
            if (status >= 200 && status < 300) {
                return new FetchResult(
                        response.uri(),
                        status,
                        response.headers(),
                        response.body());
            }

            String preview = preview(response.body(), 300);
            boolean retryable = isRetryableStatus(status);

            if (!retryable || attempt == maxAttempts) {
                String message = retryable
                        ? "retry limit reached"
                        : "non-retryable HTTP status";

                recordFailure(
                        response.uri(),
                        attempt,
                        status,
                        message,
                        preview);

                throw new IOException(
                        message + ": " + status
                                + " for " + response.uri());
            }

            Duration delay = retryAfter(
                    response.headers()).orElseGet(
                            () -> backoff(attempt));

            logRetry(
                    response.uri(),
                    status,
                    "HTTP",
                    attempt,
                    delay,
                    preview);

            Thread.sleep(delay);
        }

        throw new IllegalStateException(
                "Retry loop exited unexpectedly");
    }

    List<Failure> failures() {
        return List.copyOf(failures);
    }

    private boolean isRetryableStatus(int status) {
        return status == 408
                || status == 429
                || status == 500
                || status == 502
                || status == 503
                || status == 504;
    }

    private boolean isTransient(IOException error) {
        for (Throwable cause = error;
             cause != null;
             cause = cause.getCause()) {
            if (cause instanceof HttpTimeoutException
                    || cause instanceof ConnectException
                    || cause instanceof SocketException) {
                return true;
            }
        }
        return false;
    }

    private Optional<Duration> retryAfter(
            HttpHeaders headers) {

        return headers.firstValue("Retry-After")
                .flatMap(this::parseRetryAfter)
                .map(this::capDelay);
    }

    private Optional<Duration> parseRetryAfter(
            String rawValue) {

        String value = rawValue.trim();

        try {
            long seconds = Long.parseLong(value);
            return Optional.of(Duration.ofSeconds(
                    Math.max(0, seconds)));
        } catch (NumberFormatException ignored) {
            // Try the HTTP-date form next.
        }

        try {
            Instant retryAt = ZonedDateTime.parse(
                    value,
                    DateTimeFormatter.RFC_1123_DATE_TIME)
                    .toInstant();

            Duration delay = Duration.between(
                    Instant.now(),
                    retryAt);

            return Optional.of(
                    delay.isNegative()
                            ? Duration.ZERO
                            : delay);
        } catch (DateTimeParseException ignored) {
            return Optional.empty();
        }
    }

    private Duration backoff(int attempt) {
        long multiplier =
                1L << Math.min(attempt - 1, 10);

        long ceiling = Math.min(
                maxDelay.toMillis(),
                baseDelay.toMillis() * multiplier);

        long floor = Math.min(
                baseDelay.toMillis(),
                ceiling);

        long millis = ceiling <= floor
                ? ceiling
                : ThreadLocalRandom.current()
                        .nextLong(floor, ceiling + 1);

        return Duration.ofMillis(millis);
    }

    private Duration capDelay(Duration delay) {
        return delay.compareTo(maxDelay) > 0
                ? maxDelay
                : delay;
    }

    private void recordFailure(
            URI uri,
            int attempts,
            Integer status,
            String message,
            String bodyPreview) {

        failures.add(new Failure(
                uri,
                attempts,
                status,
                message,
                bodyPreview));
    }

    private void logRetry(
            URI uri,
            Integer status,
            String kind,
            int attempt,
            Duration delay,
            String bodyPreview) {

        System.err.printf(
                "retry url=%s status=%s kind=%s "
                        + "attempt=%d/%d waitMs=%d body=%s%n",
                uri,
                status == null ? "-" : status,
                kind,
                attempt,
                maxAttempts,
                delay.toMillis(),
                bodyPreview);
    }

    private static String preview(
            String body,
            int maxChars) {

        if (body == null) {
            return "";
        }

        String oneLine = body.replaceAll("\\s+", " ").trim();
        return oneLine.substring(
                0,
                Math.min(maxChars, oneLine.length()));
    }
}

Constrúyela con límites explícitos en lugar de constantes dispersas:

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(20))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

PageFetcher fetcher = new RetryingHttpFetcher(
        client,
        4,
        Duration.ofMillis(500),
        Duration.ofSeconds(30));

La programación local aumenta aproximadamente desde 500 milisegundos hasta 1, 2 y 4 segundos, con aleatoriedad dentro de cada límite. Un Retry-After tiene prioridad, pero esta implementación aplica el retraso máximo configurado. Si esperar menos de lo que solicita el servidor infringiría tu política operativa, detén la tarea o reprograma en lugar de limitar el intento y volver a intentarlo inmediatamente.

No añadas 403 al conjunto de reintentos por defecto. Puede significar que la solicitud está prohibida o bloqueada, y la repetición sin cambiar la causa solo genera más tráfico. Del mismo modo, 400, 401, y 404 suelen ser problemas de configuración, autenticación o URL. Una capa de reintentos en Java para el scraping web debe ser conservadora, ya que cada reintento consume el presupuesto de solicitudes.

Registra el contexto de la solicitud y resume los resultados del rastreo

Los registros de reintentos sirven para el diagnóstico, no para volcar datos. Registra la URL, el estado o el tipo de excepción, el número de intento, el tiempo de espera y una breve vista previa del cuerpo de una sola línea. Nunca registres claves de API, encabezados de autorización, cookies de sesión, respuestas completas de inicio de sesión ni cargas útiles completas de datos personales.

Realiza un seguimiento de los resultados a nivel de rastreo por separado de los intentos de solicitud:

final class CrawlMetrics {
    private final LongAdder pagesAttempted = new LongAdder();
    private final LongAdder pagesSucceeded = new LongAdder();
    private final LongAdder pagesFailed = new LongAdder();

    void attempted() {
        pagesAttempted.increment();
    }

    void succeeded() {
        pagesSucceeded.increment();
    }

    void failed() {
        pagesFailed.increment();
    }

    void printSummary() {
        System.out.printf(
                "crawl attempted=%d succeeded=%d failed=%d%n",
                pagesAttempted.sum(),
                pagesSucceeded.sum(),
                pagesFailed.sum());
    }
}

Incrementa attempted una vez cuando el rastreador acepte una página, no una vez por cada reintento. Incrementa exactamente un resultado final después de que la obtención haya tenido éxito o se haya agotado la política. Al cerrar, imprime una lista compacta de fallos a partir de RetryingHttpFetcher.failures() y los totales agregados. Esto proporciona a las ejecuciones programadas un informe de salida útil sin saturar los registros con cada respuesta satisfactoria.

Cuando la concurrencia esté habilitada, incluye un identificador de ejecución y mantén cada evento de registro en una sola línea. Esto permite realizar búsquedas en la salida entremezclada. En un registrador de producción, utiliza campos estructurados en lugar de analizar texto sin formato.

Gestiona las páginas dinámicas y las sesiones autenticadas

Cuando el extractor estático devuelva HTML válido pero no los datos que se ven en un navegador, no sustituyas inmediatamente todo el rastreador. Determina primero de dónde obtuvo el navegador el estado que falta. Mantén parseBooks() o el analizador equivalente independiente y, a continuación, cambia solo el proveedor de obtención cuando el estado de renderización o de sesión sea realmente necesario.

Inspecciona los datos incrustados y las llamadas Fetch/XHR antes de la representación

El contenido dinámico es aquel que JavaScript inserta o modifica tras la respuesta inicial. Puede que siga procediendo de una fuente de datos accesible que resulte más sencilla que la automatización del navegador.

Comprueba estas ubicaciones en este orden:

  1. Busca el valor visible en el código HTML sin procesar.
  2. Inspecciona <script type="application/ld+json"> los bloques y otros estados serializados incrustados en el documento.
  3. Vuelve a cargar la página con DevTools abierto y filtra las solicitudes de red para buscar Fetch/XHR.
  4. Selecciona la solicitud que devuelve los registros e inspecciona sus campos de método, URL, carga útil, respuesta y paginación.
  5. Confirma que se permite reproducir la operación y que las credenciales o tokens necesarios se pueden obtener de forma legítima.

En el caso de JSON-LD incrustado, Jsoup puede localizar el script, mientras que una biblioteca JSON analiza su texto:

Document document = Jsoup.parse(html, pageUri.toString());

for (Element script : document.select(
        "script[type=application/ld+json]")) {
    String json = script.data().trim();
    if (!json.isBlank()) {
        // Parse with the JSON library selected for the project.
    }
}

Para un punto final Fetch/XHR, llámalo con HttpClient igual que cualquier otra solicitud:

HttpRequest request = HttpRequest.newBuilder(apiUri)
        .timeout(Duration.ofSeconds(30))
        .header("Accept", "application/json")
        .GET()
        .build();

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

A continuación, analiza response.body() con Jackson, org.jsonu otra biblioteca de tu elección. No extraigas el DOM visual cuando la respuesta subyacente permitida ya contenga campos estructurados estables.

La inspección de red también revela si la paginación utiliza números de página, cursores, cuerpos POST o encabezados de solicitud. Sin embargo, una solicitud capturada en DevTools puede incluir firmas que caducan, autorización específica del usuario o valores anti-CSRF. Reproducirla fuera de la sesión autorizada puede fallar o infringir la política. Reproduce únicamente la solicitud mínima y estable que tu caso de uso esté autorizado a realizar.

Este flujo de trabajo de mínima complejidad es la base del scraping web dinámico en Java: inspecciona primero la ruta de los datos y, a continuación, añade la ejecución solo cuando el acceso a los datos dependa de ella.

Elige la representación gestionada de JavaScript o Selenium para un comportamiento exclusivo del navegador

Si es necesario ejecutar JavaScript, elige entre la recuperación de la página renderizada y el control total del navegador en función de la interacción requerida:

Requisito

Categoría de renderización gestionada

Selenium

Devolver el HTML una vez que los scripts se hayan ejecutado

Es una buena opción cuando el servicio admite la condición de espera necesaria

Funciona, pero hay que gestionar el ciclo de vida del navegador

Hacer clic, desplazarse, escribir, subir archivos o coordinar varios pasos

Depende del modelo de instrucciones que admita el proveedor

Muy adecuado

Reutiliza los selectores Jsoup existentes

Analiza el HTML devuelto con Jsoup

Pasar getPageSource() a Jsoup

Carga operativa

La infraestructura del navegador está alojada

Tú te encargas de gestionar los navegadores, los controladores, la memoria, los fallos y el escalado

Depura el comportamiento visual de forma local

Limitado por las herramientas del proveedor

Se adapta muy bien al modo «headed» y a las capturas de pantalla

Un renderizador gestionado debería implementar el mismo PageFetcher límite y devolver FetchResult. Esto preserva el analizador y el rastreador. No codifiques de forma rígida un adaptador de proveedor hasta que se hayan comprobado su punto final actual, la autenticación, los indicadores de renderizado, los encabezados y cookies reenviados, los límites y la semántica de los errores.

Utiliza Selenium cuando el estado requerido solo exista tras un comportamiento del navegador, como hacer clic en una pestaña, enviar un formulario, desplazarse por una lista infinita o esperar una ruta del lado del cliente. En el momento de la publicación, verifica la selenium-java versión y la configuración del controlador del navegador. Las versiones fijadas del material de referencia están sujetas a cambios.

Una ruta mínima en Java para el scraping web con Selenium tiene este aspecto:

ChromeOptions options = new ChromeOptions();
options.addArguments("--headless");

WebDriver driver = new ChromeDriver(options);

try {
    driver.get(targetUrl);

    WebDriverWait wait = new WebDriverWait(
            driver,
            Duration.ofSeconds(10));

    wait.until(
            ExpectedConditions.presenceOfElementLocated(
                    By.cssSelector(".result-card")));

    String renderedHtml = driver.getPageSource();
    URI finalUri = URI.create(driver.getCurrentUrl());

    List<Book> books = parseBooks(
            renderedHtml,
            finalUri);

    System.out.println(
            "Rendered records: " + books.size());
} finally {
    driver.quit();
}

Utiliza una espera explícita para un estado que demuestre que los datos están listos. Los tiempos de espera fijos o bien suponen una pérdida de tiempo o bien fallan cuando una página es más lenta de lo esperado. Si un clic activa la paginación, espera a que se produzca un cambio significativo —como que el contenedor de resultados anterior quede obsoleto o que se actualice un marcador de página— antes de volver a leer.

Coloca siempre quit() en finally. Cerrar solo la ventana actual puede dejar procesos del controlador en ejecución. El modo sin interfaz elimina la interfaz visible, pero no garantiza un mejor rendimiento en todas las páginas. Si necesitas información sobre qué es un navegador sin interfaz y en qué se diferencia su arquitectura de la de un cliente HTTP, considéralo un tema operativo independiente del análisis de HTML.

Ni la representación gestionada ni Selenium conceden permiso para acceder a contenido restringido. Son opciones de ejecución, no soluciones alternativas a las políticas.

Conserva las cookies con HttpClient CookieManager

La gestión de sesiones consiste en mantener el estado del usuario entre solicitudes, normalmente mediante cookies. Asigna una CookieManager a un HttpClient, envía el flujo de inicio de sesión autorizado y utiliza ese mismo cliente para las páginas posteriores.

static HttpResponse<String> loginAndFetch(
        URI loginUri,
        URI dataUri)
        throws IOException, InterruptedException {

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

    HttpClient sessionClient = HttpClient.newBuilder()
            .cookieHandler(cookieManager)
            .followRedirects(HttpClient.Redirect.NORMAL)
            .connectTimeout(Duration.ofSeconds(20))
            .build();

    String username = requireEnv("SCRAPER_USERNAME");
    String password = requireEnv("SCRAPER_PASSWORD");

    String form = "username=" + encode(username)
            + "&password=" + encode(password);

    HttpRequest login = HttpRequest.newBuilder(loginUri)
            .timeout(Duration.ofSeconds(30))
            .header(
                    "Content-Type",
                    "application/x-www-form-urlencoded")
            .POST(HttpRequest.BodyPublishers.ofString(form))
            .build();

    HttpResponse<String> loginResponse = sessionClient.send(
            login,
            HttpResponse.BodyHandlers.ofString());

    if (loginResponse.statusCode() < 200
            || loginResponse.statusCode() >= 400) {
        throw new IOException(
                "Login failed with status "
                        + loginResponse.statusCode());
    }

    HttpRequest protectedPage =
            HttpRequest.newBuilder(dataUri)
                    .timeout(Duration.ofSeconds(30))
                    .GET()
                    .build();

    return sessionClient.send(
            protectedPage,
            HttpResponse.BodyHandlers.ofString());
}

static String requireEnv(String name) {
    String value = System.getenv(name);
    if (value == null || value.isBlank()) {
        throw new IllegalStateException(
                "Missing environment variable " + name);
    }
    return value;
}

static String encode(String value) {
    return URLEncoder.encode(
            value,
            StandardCharsets.UTF_8);
}

Este ejemplo solo abarca un formulario sencillo que establece cookies. Un flujo protegido contra CSRF suele requerir una solicitud GET previa, la extracción de un token oculto u otro valor proporcionado por el servidor, y el envío de ese valor junto con el formulario. Algunas aplicaciones utilizan OAuth, autenticación multifactorial, tokens de portador, comprobaciones de dispositivo o estado vinculado al navegador. No existe una receta genérica segura para el inicio de sesión en esos sistemas.

Verifica el éxito utilizando un marcador de página autenticada o la redirección esperada, no simplemente una 200 respuesta de inicio de sesión. Limita la aceptación de cookies si el rastreo abarca dominios no relacionados y nunca registres el almacén de cookies. Una guía básica sobre cookies HTTP puede resultar útil a la hora de depurar el dominio, la ruta, la caducidad Securey SameSite comportamiento.

Valida y conserva los resultados extraídos

Los campos impresos demuestran que los selectores coincidieron en algún momento. No demuestran que la salida sea utilizable. Cambia el criterio de éxito por registros normalizados y trazables que superen las comprobaciones de campos obligatorios y lleguen a un almacenamiento duradero. Aquí es donde un rastreador de tutorial se convierte en un canal de datos fiable.

Normaliza los campos y rechaza los registros incompletos

Normaliza inmediatamente después de la extracción para que todos los componentes posteriores vean la misma estructura. Elimina los espacios en blanco repetidos, convierte los espacios no separables cuando sea necesario, rechaza los campos obligatorios en blanco y conserva la página de origen que generó cada registro.

record RawBook(
        String title,
        String priceText,
        String detailUrl,
        URI sourcePage) {}

record Book(
        String title,
        String priceText,
        URI detailUrl,
        URI sourcePage) {}

static Optional<Book> normalize(RawBook raw) {
    String title = cleanText(raw.title());
    String price = cleanText(raw.priceText());
    String detail = cleanText(raw.detailUrl());

    if (title.isBlank()
            || price.isBlank()
            || detail.isBlank()
            || raw.sourcePage() == null) {
        return Optional.empty();
    }

    URI detailUri;
    try {
        detailUri = URI.create(detail);
    } catch (IllegalArgumentException error) {
        return Optional.empty();
    }

    if (!detailUri.isAbsolute()) {
        return Optional.empty();
    }

    return Optional.of(new Book(
            title,
            price,
            detailUri,
            raw.sourcePage()));
}

static String cleanText(String value) {
    if (value == null) {
        return "";
    }

    return value
            .replace('\u00A0', ' ')
            .replaceAll("\\s+", " ")
            .trim();
}

Define los campos obligatorios en función del uso previsto, no de lo que resulte más fácil de seleccionar. Una URL de detalles puede ser esencial para la trazabilidad, mientras que una URL de imagen puede ser opcional. Si el precio se va a calcular u ordenar, analiza el importe y la moneda en campos tipados específicos para el destino. No elimines los símbolos ni des por sentado que todos los sitios web utilizan los mismos separadores decimales y de miles.

Mantén visibles los registros no válidos mediante contadores o una muestra limitada de los motivos de rechazo. Omitir silenciosamente cada discrepancia puede convertir una regresión del selector en una ejecución aparentemente exitosa con pocos datos. Al mismo tiempo, no conserves páginas sensibles completas simplemente para explicar una fila errónea.

La URL de origen debe figurar en el registro incluso cuando no forme parte del esquema empresarial final. Permite reproducir una extracción, investigar conflictos e identificar qué plantilla de página ha fallado.

Elimina duplicados y genera salida en formato JSON, CSV o base de datos

Elige una clave de deduplicación con significado en el contexto del dominio. Una URL canónica de detalle suele ser mejor que un título, ya que los títulos pueden repetirse o cambiar. Para una pequeña ejecución en memoria, conserva el orden en que se ven por primera vez con un LinkedHashMap:

Map<URI, Book> uniqueByUrl = new LinkedHashMap<>();

for (Book book : normalizedBooks) {
    uniqueByUrl.putIfAbsent(
            normalize(book.detailUrl()),
            book);
}

List<Book> uniqueBooks =
        List.copyOf(uniqueByUrl.values());

Si las páginas posteriores contienen valores más recientes, utiliza una regla de fusión explícita en lugar de putIfAbsent. Registra el recuento de duplicados en cualquier caso.

Salida

Mejor ajuste

Precaución principal

CSV

Registros planos utilizados por hojas de cálculo o tareas por lotes sencillas

Escapar las comas, las comillas y los saltos de línea

JSON

Registros anidados, API, archivos o evolución de esquemas

Utiliza un serializador JSON en lugar de cadenas creadas manualmente

Base de datos

Cargas incrementales, consultas, restricciones e historial de ejecuciones múltiples

Escrituras por lotes, uso de transacciones y definición de claves únicas

Un escritor de CSV sin dependencias para el Book registro puede tener este aspecto:

static void writeCsv(
        Path path,
        Iterable<Book> books)
        throws IOException {

    try (BufferedWriter writer = Files.newBufferedWriter(
            path,
            StandardCharsets.UTF_8)) {

        writer.write(
                "title,price,detail_url,source_page");
        writer.newLine();

        for (Book book : books) {
            writer.write(String.join(",",
                    csv(book.title()),
                    csv(book.priceText()),
                    csv(book.detailUrl().toString()),
                    csv(book.sourcePage().toString())));
            writer.newLine();
        }
    }
}

static String csv(String value) {
    String escaped = value.replace("\"", "\"\"");
    return "\"" + escaped + "\"";
}

En el caso de JSON, una biblioteca como Jackson puede serializar registros Java tras seleccionar y verificar la versión de sus dependencias. Para una base de datos, utiliza sentencias preparadas de JDBC, agrupa un número limitado de registros, confirma deliberadamente y deja que una restricción única imponga la regla de identidad.

Para la salida a archivo, escribe en una ruta temporal y muévelo a su ubicación definitiva solo después de que el escritor se haya cerrado correctamente. Esto evita que una ejecución fallida presente un archivo CSV o JSON a medio escribir como un conjunto de datos completo.

No mantengas un conjunto de resultados ilimitado simplemente para escribirlo al final. Transmite los registros aceptados a un escritor, ponlos en cola en un único consumidor que escriba en la base de datos o vacía lotes limitados. Mantén el límite de concurrencia de la red separado de la capacidad de la cola de salida, de modo que el almacenamiento lento genere contrapresión en lugar de un crecimiento ilimitado de la memoria.

Supervisa el estado de la extracción mediante recuentos y avisos de resultados vacíos

Un estado HTTP correcto puede seguir generando cero registros debido a que ha cambiado un selector, ha aparecido una página de consentimiento o la solicitud ha recibido una plantilla diferente. Realiza un seguimiento de las métricas de páginas y registros en el límite del analizador:

final class ExtractionMetrics {
    private final LongAdder pagesParsed = new LongAdder();
    private final LongAdder emptyPages = new LongAdder();
    private final LongAdder accepted = new LongAdder();
    private final LongAdder rejected = new LongAdder();
    private final LongAdder duplicates = new LongAdder();

    void recordPage(
            URI uri,
            int containers,
            int acceptedOnPage,
            int rejectedOnPage) {

        pagesParsed.increment();
        accepted.add(acceptedOnPage);
        rejected.add(rejectedOnPage);

        if (containers == 0 || acceptedOnPage == 0) {
            emptyPages.increment();
            System.err.printf(
                    "empty-extraction url=%s "
                            + "containers=%d accepted=%d "
                            + "rejected=%d%n",
                    uri,
                    containers,
                    acceptedOnPage,
                    rejectedOnPage);
        }
    }

    void duplicate() {
        duplicates.increment();
    }

    void printSummary() {
        System.out.printf(
                "extraction pages=%d empty=%d "
                        + "accepted=%d rejected=%d "
                        + "duplicates=%d%n",
                pagesParsed.sum(),
                emptyPages.sum(),
                accepted.sum(),
                rejected.sum(),
                duplicates.sum());
    }
}

Pasa el número de contenedores seleccionados por separado de los registros aceptados. Un número cero de contenedores sugiere que la estructura de la página o la respuesta es incorrecta. Los contenedores con cero registros aceptados sugieren que la validación de los campos obligatorios está fallando. Se trata de incidencias diferentes.

Para las ejecuciones programadas de scraping web en Java, compara los recuentos con una expectativa configurada derivada de tu propio historial, no de un punto de referencia universal. Avisa en caso de ceros inesperados, cambios bruscos o una alta tasa de rechazo, y luego conserva una pequeña muestra de diagnóstico. Mantén los registros normales concisos: una línea por fallo de solicitud, una línea de advertencia de extracción cuando sea necesario y un resumen de la ejecución.

Prepara el rastreador para un uso responsable en producción

La preparación para producción tiene que ver principalmente con los límites. Define qué puede solicitar el rastreador, cuánto trabajo puede realizar, qué datos puede conservar y cómo se detiene. Estos controles son más importantes que añadir otra biblioteca a una pila de Java para el scraping web.

Establece límites de rastreo, presupuestos de solicitudes, claves secretas y comprobaciones de políticas

Configura una lista de permitidos con esquemas, hosts y prefijos de ruta. Rechaza las redirecciones fuera del sitio y los enlaces siguientes antes de programarlos. Establece un número máximo de páginas, un tiempo máximo transcurrido, un tamaño máximo de respuesta, la concurrencia por host, el número total de intentos de solicitud y un límite de salida. Un presupuesto de solicitudes evita que un error en el selector o un calendario cíclico se conviertan en un rastreo sin fin.

Limita el rendimiento con un tope de concurrencia y, cuando sea apropiado, un intervalo mínimo entre solicitudes. Ten en cuenta las respuestas de limitación de tasa y reduce la presión en lugar de tratar los reintentos como un rendimiento adicional. Obtén solo los recursos necesarios para el conjunto de datos, almacena en caché las respuestas de descubrimiento y evita descargar imágenes, scripts o hojas de estilo cuando el HTML directo sea suficiente.

Mantén los secretos fuera del control de código fuente. Lee las credenciales y las claves de API a partir de variables de entorno o de un gestor de secretos, valídalas al inicio y ocúltalas en los registros. Revisa los encabezados HTTP para el rastreo web como entradas del protocolo, no como elementos decorativos. Envía solo los encabezados que tu solicitud necesite y utiliza un identificador de aplicación válido cuando el destino lo requiera.

Antes de la recopilación, revisa cuatro áreas distintas:

  • Legislación aplicable: los requisitos varían según la jurisdicción, el tipo de datos, el método de acceso y el uso.
  • Condiciones del sitio web: las restricciones contractuales y los usos permitidos pueden diferir de la accesibilidad técnica.
  • Privacidad: minimiza los datos personales, define el periodo de conservación, protege los resultados y documenta la finalidad.
  • robots.txt: trata las directivas de los rastreadores como una señal operativa y revisa el Protocolo de Exclusión de Robots estandarizado.

Ninguna de estas comprobaciones sustituye a las demás, y este tutorial no constituye asesoramiento jurídico. Un marco específico de cumplimiento normativo para el web scraping puede facilitar la revisión, pero la redacción final y el caso de uso concreto deben someterse al proceso habitual de cumplimiento normativo o editorial del sitio web.

Utiliza una lista de comprobación concisa para la puesta en marcha

Antes de programar el rastreador, comprueba que:

Ejecuta primero una prueba «canario» deliberadamente pequeña. Inspecciona su distribución de estados, recuentos de registros, motivos de rechazo, archivo de salida y comportamiento de limpieza antes de aumentar el presupuesto de la página.

Puntos clave

  • Empieza con la respuesta sin procesar. Utiliza HttpClient y Jsoup para el HTML del servidor, da preferencia a una fuente JSON autorizada cuando exponga los registros, y añade la ejecución en el navegador solo cuando sea necesario.
  • Mantén separados la obtención, el análisis, el rastreo y la salida para que los mismos selectores se mantengan intactos ante cambios en los reintentos, las sesiones, la representación o el proveedor de solicitudes.
  • Establece límites estrictos en cuanto a paginación, concurrencia, intentos, tiempo transcurrido y tamaño de salida. Los hilos virtuales siguen necesitando un semáforo o un límite equivalente en tiempo real.
  • Reintenta solo los fallos transitorios plausibles, respeta Retry-After, utiliza un retroceso limitado con fluctuación y conserva los fallos terminales con suficiente contexto para diagnosticarlos.
  • Trata la validación de registros, la deduplicación, las advertencias de resultados vacíos y la persistencia duradera como parte de la corrección del scraping web en Java, no como una limpieza posterior a la extracción.

Preguntas frecuentes

Estas respuestas abordan los límites de las herramientas que a menudo provocan una sobrediseño. Elige en función de dónde se encuentren los datos, qué transiciones de estado se requieran, si es seguro repetir una solicitud y qué límites imponen el destino y tu propio entorno de ejecución. Vuelve a comprobar esas suposiciones cada vez que cambie la página o el modelo de acceso.

¿Puede Jsoup ejecutar JavaScript?

No, Jsoup no ejecuta JavaScript. Analiza el HTML o XML que le proporciones y expone API para recorrer el DOM y selectores CSS. No hace clic en controles, no espera a que se produzcan actualizaciones asíncronas, no mantiene un DOM del navegador en tiempo real ni expone variables de JavaScript, a menos que estas se hayan serializado en el HTML. Aún así, puedes utilizar Jsoup después de que otro componente haya renderizado una página, pasándole el código final. Puede leer el texto de un elemento `script`, pero no puede evaluar ese script.

¿Cuándo debería un scraper de Java utilizar Selenium en lugar de HttpClient y Jsoup?

Utiliza Selenium cuando los datos o el estado requeridos solo existan tras un comportamiento real del navegador, como hacer clic, desplazarse, rellenar un formulario, gestionar una ruta del lado del cliente o esperar a actualizaciones impulsadas por JavaScript. También resulta útil para depurar cómo una página llega a ese estado. Si el HTML inicial o una solicitud JSON autorizada contienen los datos, HttpClient y Jsoup son más sencillos, más determinísticos y más fáciles de manejar. Un navegador real añade tiempo de arranque, consumo de memoria, el ciclo de vida del controlador y más modos de fallo.

¿Cómo debo limitar las solicitudes simultáneas en un scraper de Java?

Establece un límite explícito de solicitudes en curso y ajústalo gradualmente partiendo de un valor bajo. Con un ejecutor fijo, el tamaño del grupo de tareas es el límite máximo. Con subprocesos virtuales, utiliza un semáforo o un limitador de frecuencia, ya que el ejecutor puede crear muchas tareas. Aplica límites por host, limita el trabajo en cola y los búferes de salida, supervisa la latencia y las distribuciones de estado, y reduce la concurrencia cuando aumente la limitación de velocidad o los fallos. Mantén un límite global independiente si un proceso visita varios hosts.

¿Qué errores HTTP debe reintentar un rastreador Java?

Reintentar 429, respuestas seleccionadas 5xx respuestas como 500, 502, 503, y 504, los tiempos de espera de las solicitudes y los fallos transitorios de conexión cuando haya un límite de intentos. Respeta Retry-After y, en caso contrario, utiliza un retroceso exponencial con fluctuación. No repitas la mayoría de las 4xx respuestas hasta que se haya corregido la causa. Hay que tener especial cuidado con las solicitudes POST no idempotentes, ya que repetirlas puede provocar acciones duplicadas en el servidor, incluso tras un tiempo de espera agotado. Registra el estado final o la excepción una vez agotado el límite de reintentos.

Conclusión

Un scraper de Java fiable comienza con una decisión correcta sobre el acceso a los datos. Examina el HTML devuelto y el tráfico de red del navegador antes de elegir herramientas. Para páginas estáticas, reutiliza un HttpClient, analízalo con Jsoup, modela los registros tipados y mantén el analizador independiente de los detalles de transporte. A continuación, amplía la misma base de código con paginación limitada, URL visitadas, HTML almacenado en caché, límites de concurrencia explícitos, reintentos selectivos, sesiones respaldadas por cookies, validación y salida duradera.

Los controles importantes son visibles y finitos: límites de páginas, plazos de solicitud, permisos de solicitud en curso, intentos de reintento, diagnóstico de respuestas, campos obligatorios, límites de salida y cierre limpio. Esos controles hacen que los fallos sean explicables. También te permiten modificar una capa sin desestabilizar el resto del proceso de scraping web en Java.

Utiliza Selenium solo cuando la interacción con el navegador forme parte de los requisitos, no porque una página utilice JavaScript por casualidad. Si ese flujo de trabajo requiere realmente clics, desplazamiento, formularios o un estado renderizado sin que tengas que gestionar tú mismo la infraestructura del navegador, la API de navegador de WebScrapingAPI es una opción alojada razonable que puedes evaluar.

Empieza con una página autorizada, guarda una configuración de análisis y ejecuta una pequeña prueba piloto. Una vez que los recuentos, los fallos y los resultados parezcan correctos, aumenta gradualmente el presupuesto de rastreo sin dejar de respetar las restricciones del objetivo y tu propia revisión de cumplimiento normativo.

Acerca del autor

Raluca Penciuc, Desarrollador full-stack @ WebScrapingAPI

Raluca Penciuc

Desarrollador full-stack

Raluca Penciuc es desarrolladora full stack en WebScrapingAPI, donde se dedica a crear rastreadores, mejorar las técnicas de evasión y buscar formas fiables de reducir la detección en los sitios web de destino.

Extracción de datos web con AWS Lambda: guía para Python y Java 2026
Guías

Extracción de datos web con AWS Lambda: guía para Python y Java 2026

En resumen: el web scraping con AWS Lambda funciona mejor cuando cada invocación es breve, está delimitada y se puede reintentar de forma independiente. Empieza con HTTP directo, AWS SAM y S3, y añade SQS, contenedores, renderización en navegador, proxies o una capa de recuperación gestionada solo cuando la carga de trabajo demuestre que los necesita.

Suciu Dan33 min read
Leer artículo
Cómo utilizar GoSpider: rastrear, depurar URL y extraer datos
Guías

Cómo utilizar GoSpider: rastrear, depurar URL y extraer datos

En resumen: GoSpider es un rastreador de línea de comandos diseñado para descubrir URL, no un extractor completo de datos estructurados. Esta guía sobre cómo utilizar GoSpider muestra cómo realizar un rastreo limitado, gestionar correctamente los resultados, pasar los datos de Colly a CSV y seguir una ruta de diagnóstico para respuestas 403 o páginas que requieren renderización con JavaScript.

Suciu Dan24 min read
Leer artículo
Cómo raspar Redfin: Guía Python de Datos Inmobiliarios
Guías

Cómo raspar Redfin: Guía Python de Datos Inmobiliarios

TL;DR: Redfin expone puntos finales de API ocultos que devuelven JSON estructurado para los listados de propiedades, lo que permite omitir por completo el frágil análisis HTML. Esta guía te guía a través de la construcción de un raspador de Python que extrae datos de alquiler y venta, busca por ubicación, supervisa los nuevos listados a través de mapas de sitio XML y exporta resultados limpios a CSV o JSON.

Suciu Dan14 min read
Leer artículo

Empieza a crear

¿Estás listo para ampliar tu recopilación de datos?

Únete a más de 2000 empresas que utilizan WebScrapingAPI para extraer datos de la web a escala empresarial sin ningún gasto de infraestructura.