Volver al blog

Raspado web en Java con jsoup: obtener, analizar y renderizar

Ava Wilson
Ava Wilson

Expert in Web Scraping Technologies

04-Aug-2026

TL;DR:

  • jsoup es un analizador HTML de Java con un motor de selectores CSS, y proporciona su propio cliente HTTP, por lo que un scraper funcional es una dependencia y alrededor de veinte líneas.
  • jsoup mantiene solo los primeros 2 MiB de una respuesta por defecto. En una página de 2,235,648 bytes, mantuvo exactamente 2,097,152 bytes, descartó 68 de 255 entradas de referencia, perdió una sección completa y no desechó nada.
  • attr("href") devuelve el href exactamente como está escrito en el HTML. attr("abs:href") lo resuelve según la URI base del documento.
  • jsoup no ejecuta JavaScript. En una página renderizada por el cliente, el mismo código de selector encontró 0 elementos directamente y 10 elementos cuando el HTML llegó pre-renderizado.
  • Rutar la extracción a través de la Scrapeless Universal Scraping API solo cambia el transporte: el método de análisis permanece sin cambios.
  • El plan gratuito de Scrapeless es suficiente para realizar cada solicitud en esta guía.

Java es donde terminan muchos de los datos extraídos. Si el servicio que consume los datos es una aplicación Spring o Quarkus, mantener la extracción en la misma JVM elimina una frontera de lenguaje, un paso de serialización y un segundo objetivo de implementación.

La biblioteca que hace eso práctico es jsoup. Analiza HTML del mundo real como lo hace un navegador: cerrando etiquetas no cerradas, corrigiendo la anidación y normalizando atributos, siguiendo las reglas de manejo de errores en la especificación de análisis HTML — y luego expone el resultado a través de una API de selectores CSS.

Esta guía construye un scraper funcional contra dos sitios en vivo, y luego mide dos comportamientos que determinan si el scraper es correcto: lo que jsoup descarta silenciosamente en una página grande y lo que devuelve en una página que se renderiza en el navegador.

Lo que jsoup te ofrece

jsoup es primero un analizador y segundo un cliente HTTP. Jsoup.connect(url) devuelve un constructor de solicitudes fluido; .get() realiza la solicitud y devuelve un Document que puedes consultar con selectores.

java Copy
String url = "https://books.toscrape.com/";
Document doc = Jsoup.connect(url).get();
String title = doc.selectFirst("h1").text();

Ese Document es un árbol analizado en lugar de una cadena, por lo que una página mal formada aún produce una estructura consultable. También lleva la URI base de la que se obtuvo, que es lo que hace posible la resolución de URL absolutas más adelante.

Lo que jsoup no hace es ejecutar scripts. No tiene motor JavaScript y no hay bucle de eventos DOM. Lo que sea que el servidor envíe es lo que puedes analizar.

Configura el Proyecto

Una dependencia cubre el análisis. Gson está aquí solo para leer el sobre JSON en la última sección; si omites esa sección, puedes descartarlo.

xml Copy
<dependencies>
  <dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.21.1</version>
  </dependency>
  <dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.14.0</version>
  </dependency>
</dependencies>

Fija el plugin del compilador explícitamente. Maven 3.8.7 todavía asocia maven-compiler-plugin 3.1 por defecto, lo que ignora maven.compiler.release y se detiene con La opción de fuente 5 ya no es compatible:

xml Copy
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.15.0</version>
    </plugin>
    <plugin>
      <groupId>org.codehaus.mojo</groupId>
      <artifactId>exec-maven-plugin</artifactId>
      <version>3.5.0</version>
      <configuration>
        <mainClass>com.example.Scraper</mainClass>
      </configuration>
    </plugin>
  </plugins>
</build>

Con maven.compiler.release establecido en 17, el código a continuación se compila en cualquier JDK actual. La ejecución de verificación utilizó OpenJDK 21.0.11.

Recupera una Página y Analízala

Establece un agente de usuario y un tiempo de espera en cada solicitud. El agente predeterminado de jsoup se identifica como jsoup, y muchos sitios varían su respuesta solo en función de eso.

java Copy
static final String USER_AGENT =
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    + "(KHTML, como Gecko) Chrome/140.0.0.0 Safari/537.36";

static Document fetch(String url) throws IOException {
    return Jsoup.connect(url)
        .userAgent(USER_AGENT)
        .timeout(30_000)
        .get();
}

timeout está en milisegundos y se aplica tanto a la conexión como a la lectura. Un get() que lo exceda genera SocketTimeoutException; un estado no-2xx genera HttpStatusException, que lleva el código.

Selecciona los Datos

Los selectores son CSS estándar. select devuelve cada coincidencia como una colección de Elements; selectFirst devuelve un Element o null.

java Copy
record Book(String title, String price, String url) {}

static List<Book> books(Document doc) {
    List<Book> found = new ArrayList<>();
    for (Element card : doc.select("article.product_pod")) {
        Element link = card.selectFirst("h3 > a");
        found.add(new Book(
            link.attr("title"),
            card.selectFirst("p.price_color").text(),
lo.link.attr("abs:href")));
    }
    return encontrado;
}

Contra el catálogo en vivo, esto devuelve 20 libros, siendo el primero A Light in the Attic a £51.77.

El prefijo abs: en esa última línea está haciendo un trabajo real. El enlace en la fuente dice:

text Copy
catalogue/a-light-in-the-attic_1000/index.html

attr("href") te da esa cadena tal cual. Prefijando el nombre del atributo con abs: se resuelve contra la URI base del documento utilizando el algoritmo en el Estándar de URL de WHATWG, produciendo:

text Copy
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html

Un scraper que almacena el valor en bruto funciona bien hasta el día en que algo más intenta recuperarlo.

El Límite Que Te Custa Datos

jsoup limita el cuerpo de la respuesta que leerá. El valor predeterminado es 2 MiB, documentado en la interfaz de solicitud de jsoup. Pasado ese punto, deja de leer y analiza lo que tiene. No lanza ninguna excepción, no registra ninguna advertencia y no deja ninguna bandera en el Document para indicar que el cuerpo fue recortado.

Usa execute() en lugar de get() cuando quieras ver la respuesta antes de analizarla:

java Copy
Connection.Response limitado = Jsoup.connect(grande)
    .userAgent(USER_AGENT).timeout(60_000).execute();
Connection.Response completo = Jsoup.connect(grande)
    .userAgent(USER_AGENT).timeout(60_000).maxBodySize(0).execute();

Ejecutados contra una página de comparación de Wikipedia de 2,235,648 bytes, las dos respuestas difieren exactamente por el límite:

text Copy
estado http, límite predeterminado: 200
bytes recibidos, límite predeterminado: 2097152
bytes recibidos, maxBodySize(0): 2235648
filas de la tabla, límite predeterminado: 544
filas de la tabla, maxBodySize(0): 544
entradas de referencia, límite predeterminado: 187
entradas de referencia, maxBodySize(0): 255
última sección, límite predeterminado: Referencias
última sección, maxBodySize(0): Enlaces externos

Ambas ejecuciones devolvieron HTTP 200. Ambas produjeron un Document. Las tablas de comparación para las cuales existe la página pasaron intactas, 544 filas en ambos casos, porque están cerca de la parte superior del documento.

Todo lo que está debajo del corte simplemente está ausente. La lista de referencias perdió 68 de sus 255 entradas, y la sección final Enlaces externos no existe en el árbol truncado en absoluto. Un scraper que lee las tablas nunca lo notaría; un scraper que recopila citaciones reportaría silenciosamente menos de una cuarta parte y aún se vería saludable.

maxBodySize(0) elimina el límite. Establecerlo deliberadamente en lugar de por reflejo; una lectura sin límites en una respuesta inesperada es su propio problema; pero configurarlo conscientemente y compararlo con el Content-Length que el servidor informa, que la especificación de semántica HTTP define como el recuento de octetos del cuerpo.

https://quotes.toscrape.com/js/ marca el límite. Sirve sus citas como un array de JavaScript y construye el DOM del lado del cliente, y jsoup lo recupera exitosamente:

text Copy
bytes html recuperados directamente: 5479
citas encontradas por jsoup:       0

5,479 bytes, HTTP 200, cero resultados. El marcado que recibió jsoup realmente no contiene elementos div.quote — se crean después de que se ejecuta el script, y jsoup no tiene un motor de scripts.

La mayoría de las guías responden a esto cambiando a una herramienta de automatización de navegador, lo que significa reescribir el código de extracción también. Ese intercambio es el mismo que Cheerio y Puppeteer tienen de un lado a otro en Node, y cuesta lo mismo en Java. La solución más estrecha es cambiar solo cómo llega el HTML. Ese trabajo pertenece a Scrapeless Universal Scraping API. Renderiza la página y devuelve el HTML resultante como una cadena, que le pasas al mismo analizador.

El HttpClient incorporado en JDK es suficiente; no se requiere dependencia de HTTP:

java Copy
static String render(String url) throws IOException, InterruptedException {
    JsonObject entrada = new JsonObject();
    entrada.addProperty("url", url);
    entrada.addProperty("proxy_country", "US");
    entrada.addProperty("js_render", true);

    JsonObject carga = new JsonObject();
    carga.addProperty("actor", "unlocker.webunlocker");
    carga.add("input", entrada);

    HttpRequest solicitud = HttpRequest.newBuilder()
        .uri(URI.create("https://api.scrapeless.com/api/v2/unlocker/request"))
        .header("Content-Type", "application/json")
        .header("x-api-token", System.getenv("SCRAPELESS_API_KEY"))
        .timeout(Duration.ofSeconds(180))

.POST(HttpRequest.BodyPublishers.ofString(payload.toString(), StandardCharsets.UTF_8))
.build();

Copy
HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
    throw new IOException("el desbloqueador devolvió HTTP " + response.statusCode());
}
return JsonParser.parseString(response.body())
    .getAsJsonObject().get("data").getAsString();

}

Copy
El sobre de respuesta es `{"code": ..., "data": "<html renderizado>"}`. Analiza esa cadena con `Jsoup.parse(html, url)` — pasando la URL establece el URI base, por lo que `abs:href` sigue funcionando — y ejecuta el mismo método de selector:

```java
static List<String> quotes(Document doc) {
    List<String> found = new ArrayList<>();
    for (Element quote : doc.select("div.quote")) {
        found.add(quote.selectFirst("span.text").text()
            + " -- " + quote.selectFirst("small.author").text());
    }
    return found;
}
text Copy
bytes de html renderizado:            8940
citas encontradas por el mismo analizador: 10

El mismo método, los mismos selectores, misma API de Document. Lo único que ha cambiado es de dónde provinieron los 8,940 bytes. Mantén tu clave API en el entorno, como SCRAPELESS_API_KEY arriba, en lugar de en el código fuente. La guía de inicio rápido de la API de Scraping Universal cubre los restantes parámetros de solicitud.

Empezar lleva un minuto — crea una cuenta gratuita en Scrapeless y el plan gratuito cubre todo lo de esta guía.

Ejecútalo

Con la clase ensamblada, un comando la compila y ejecuta:

bash Copy
export SCRAPELESS_API_KEY="tu-clave-api"
mvn -q -B compile exec:java

La salida completa de la ejecución de verificación:

text Copy
jsoup 1.21.1 | java 21.0.11
--- página estática, analizada directamente ---
libros en la página 1: 20
primer título: Una luz en el desván
primer precio: £51.77
primer url:   https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
href como se escribió en el HTML: catalogue/a-light-in-the-attic_1000/index.html
--- límite de tamaño de cuerpo por defecto ---
estado http, límite por defecto: 200
bytes recibidos, límite por defecto: 2097152
bytes recibidos, maxBodySize(0): 2235648
filas de tabla, límite por defecto: 544
filas de tabla, maxBodySize(0): 544
entradas de referencia, límite por defecto: 187
entradas de referencia, maxBodySize(0): 255
última sección, límite por defecto: Referencias
última sección, maxBodySize(0): Enlaces externos
--- página renderizada por javascript ---
bytes de html obtenidos directamente: 5479
citas encontradas por jsoup:       0
--- misma página a través de la API de Scraping Universal ---
bytes de html renderizado:            8940
citas encontradas por el mismo analizador: 10

Solución de problemas

La opción de origen 5 ya no es compatible. Maven vinculó su maven-compiler-plugin predeterminado en lugar del suyo. Fije la versión del plugin en <build><plugins> como se mostró arriba.

selectFirst devolvió null y la siguiente línea lanzó NullPointerException. El selector no coincidió con nada. Verifique el elemento con respecto al HTML que jsoup realmente recibió — doc.html() — no con lo que muestra el inspector del navegador, que es el DOM posterior al script.

Los conteos son más bajos de lo que muestra la página. Compare response.bodyAsBytes().length con el Content-Length del servidor. Si coinciden en 2,097,152, el límite de tamaño del cuerpo es la causa.

HttpStatusException: 403. El servidor rechazó la solicitud en lugar del análisis. Establezca primero un agente de usuario realista; si la página también requiere renderizado, se aplica el pivote anterior.

Mojibake en el texto extraído. jsoup lee el conjunto de caracteres del encabezado Content-Type, luego de la etiqueta meta. Cuando un servidor no declara ninguno, pase la codificación explícitamente a Jsoup.parse(InputStream, String, String).

Conclusión

Para HTML estático, jsoup y el JDK son toda la cadena de herramientas: una dependencia, una solicitud fluida, una API de selector CSS y un árbol analizado que sobrevive a un mal marcado. Las dos comportamientos que deciden si los resultados son confiables son invisibles por defecto: el límite del cuerpo de 2 MiB que devuelve un documento parcial que parece saludable, y el conjunto de resultados vacío en una página renderizada por el cliente.

Ambos son baratos de verificar. Compare los bytes recibidos con Content-Length, y compare el conteo de un selector con lo que muestra la página. Cuando la segunda verificación falla porque el marcado llega vacío, la solución es cambiar el transporte y dejar el analizador en paz.
¿Listo para intentarlo? Comienza con el plan gratuito de Scrapeless y consulta los precios actuales para volúmenes más altos.

FAQ

P: ¿Es suficiente jsoup por sí solo, o necesito Selenium?

Para HTML renderizado en el servidor, jsoup por sí solo es suficiente y considerablemente más rápido, ya que nunca inicia un navegador. Necesitas un paso de renderizado solo cuando los datos son creados por JavaScript, y como muestra la medición anterior, ese paso puede ser una llamada HTTP que devuelve HTML renderizado en lugar de un navegador completo en tu proceso.

P: ¿Cómo puedo saber si una página necesita JavaScript antes de escribir selectores?

Imprime doc.html() desde una obtención simple de jsoup y busca un valor que puedas ver en la página. Si el valor está ausente de esa cadena pero visible en el navegador, se está renderizando del lado del cliente. Comparar un conteo de selectores con el conteo visible captura lo mismo.

P: ¿Cuál es la razón práctica para cambiar maxBodySize?

El valor predeterminado mantiene 2 MiB, que es menor que muchas páginas de listas, archivos y comparaciones. Si tu objetivo lo excede, todo lo que pasa el límite estará ausente del árbol analizado sin que se genere un error. Establece maxBodySize(0) cuando necesites el documento completo y compara los bytes recibidos con Content-Length para saber cuándo importa.

P: ¿jsoup maneja HTML malformado?

Sí. Aplica las mismas reglas de recuperación de errores que un navegador, por lo que las etiquetas no cerradas y los elementos mal anidados aún producen un árbol consultable. Esa es la razón principal para preferirlo sobre un analizador XML estricto para páginas del mundo real.

P: ¿Es legal raspar con Java?

El lenguaje no es relevante para la cuestión legal. Lo que importa es los datos que recoges, los términos del sitio, las directivas de robots que respetas y la jurisdicción en la que operas. Restringe la recolección a páginas públicas, mantén un volumen de solicitudes modesto y busca asesoramiento legal para cualquier cosa que involucre datos personales.

En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.

Artículos más populares

Catalogar