Implementando un crawler sencillo con Jsoup

1
9387

Con un crawler podemos examinar un sitio buscando todos sus enlaces para, después, poder buscar lo que deseemos en cada página. Es el primer paso para poder acceder a la información. El objetivo de este tutorial es indexar todas las URLs de un sitio web y poder acceder fácilmente a toda su información de una manera más ordenada.

El web scraping es el arte de extraer datos de sitios web. Normalmente, en la web los documentos vienen en formatos estándar HTML y de forma desestructurada. Los datos están ahí, pero para darles uso es necesaro interpretarlos y estructurarlos de forma accesible y útil. Una persona lee una web y entiende lo que pone pero ¿y si queremos automatizarlo? ¿Y si queremos analizar todos los productos de una categoría de Amazon, por ejemplo, y que nos avise cuando alguno baja de precio? ¿y si queremos tomar estadísticas de documentos no tabulados? Es entonces cuando un trabajo manual se vuelve demasiado largo y tedioso, y para eso están los crawlers o spiders, que nos ayudan automatizando esta labor.

Qué es

Jsoup es una libreria Java que proporciona operaciones para trabajar con HTML. Permite extraer y manipular datos, que podrán ser utilizados convenientemente para nuestras necesidades.

Con Jsoup podemos construir desde parseadores básicos de HTML para analizar y procesar páginas estáticas hasta herramientas de análisis recursivo de sitios completos (crawlers o spiders). No obstante, Jsoup está más pensado para análisis de páginas estáticas que para un crawler complejo. Si lo que queremos es recopilar diferentes tipos de datos de un sitio completo independientemente de sus URLs, puede ser más adecuado utilizar Crawler4j.

Cómo usar Jsoup

Para poder utilizar Jsoup basta con descargarse su jar correspondiente desde la web oficial. No obstante, se encuentra en el repositorio oficial de Maven. Por tanto, usaremos mejor la dependencia Maven para este caso. Podeis acceder al código fuente de este proyecto maven de ejemplo en este repositorio de GitHub.

<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.11.3</version>
</dependency>

Con la librería ya importada, creamos un main vacío para ejecutar ejemplos. Además, para tratar de hacer el ejemplo con mejor código, en mi caso he creado una clase aparte en la que iré metiendo las operaciones que luego llamaremos desde el main. Esta clase la he llamado “ParserEngine”. Pero esto queda a vuestra libertad.

Para los ejemplos que vamos a ejecutar partiremos de un ejemplo base de la documentación oficial, que iremos modificando para indexar todas las URLs de la web que deseemos.

Ejemplo básico: lista de las URLs de una página determinada.

El primer método, basado (como he indicado) en un ejemplo de la documentación, recopilará e imprimirá en pantalla todas las URLs encontradas a lo largo de todo el documento HTML obtenido de una URL concreta.

En la clase ParserEngine insertamos este nuevo método:

public void listAllLinks(String url) throws IOException {
   System.out.println("Parsing page " + url + "...");

   Document doc = Jsoup.connect(url).get();
   Elements links = doc.select("a[href]");
   Elements media = doc.select("[src]");
   Elements imports = doc.select("link[href]");

   print("\nMedia: (%d)", media.size());
   for (Element src : media) {
       if (src.tagName().equals("img"))
           print(" * %s: <%s> %sx%s (%s)",
                   src.tagName(), src.attr("abs:src"), src.attr("width"), src.attr("height"),
                   trim(src.attr("alt"), 20));
       else
           print(" * %s: <%s>", src.tagName(), src.attr("abs:src"));
   }

   print("\nImports: (%d)", imports.size());
   for (Element link : imports) {
       print(" * %s <%s> (%s)", link.tagName(),link.attr("abs:href"), link.attr("rel"));
   }

   print("\nLinks: (%d)", links.size());
   for (Element link : links) {
       print(" * a: <%s>  (%s)", link.attr("abs:href"), trim(link.text(), 35));
   }

}

Elementos clave de la librería

En este extracto de código vemos algunos elementos clave de Jsoup:

  • Document: es el objeto base de la librería. Contiene el HTML parseado de la dirección que estamos inspeccionando. Podemos obtener todo el código del contenido con el método document.outerHtml().
  • Element: es el componente mínimo de document. En plural, Elements, hace referencia a una lista de los mismos (extiende de ArrayList<Element>).
  • Método select(): este método es el que hace la magia de Jsoup. Es implementado tanto por Document como por Element y Elements. Admite buscar elementos CSS e incluso jquery. De esta forma, conociendo un poco cómo está construido el sitio web que queramos analizar, podemos buscar elementos concretos. En la documentación oficial viene bastante bien definido. Podemos buscar por nombre de elemento (como <id id = «myDiv»> o incluso clases). Es bastante potente este método y admite incluso expresiones regulares. Para hacer un buen scraper es vital saber qué queremos buscar y saber qué expresion utilizar con este método.

Par poder ejecutar exitosamente el ejemplo anterior, como habrás observado, es necesario tener implementados los métodos print y trim, que generarán las cadenas de texto propias para formatear el texto e imprimirlo. Os los dejo aquí:

private static void print(String msg, Object... args) {
   System.out.println(String.format(msg, args));
}

private static String trim(String s, int width) {
   if (s.length() > width)
       return s.substring(0, width-1) + ".";
   else
       return s;
}

Hasta ahora, nada nuevo. Lo único que hacemos es hacer una petición de una página y buscar ciertos elementos en el HTML devuelto.

Probando el ejemplo: imprimiendo los links de una página

Editamos nuestro main para crear un ejemplo de ejecución a cualquier web. En el caso de ejemplo usaré mi blog personal (así aprovecho y hago un poco de spam).

public static void main(String[] args) throws IOException {
   ParserEngine parser = new ParserEngine();
   String url = "http://elfreneticoinformatico.com";
   parser.listAllLinks(url);
}

Obtenemos un resultado como este:

Result of Jsoup default doc exampleProbablemente, sobretodo si lees esto desde un dispositivo móvil, la imagen se vea demasiado pequeña. Te recomiendo encarecidamente que la amplíes. Obtenemos, como vimos en el código, 3 listas de enlaces.

Tipos de datos obtenidos

Con este ejemplo podemos ver un ejemplo de 3 tipos de búsquedas que podemos hacer:

  • Media: enlaces a contenido insertado en el documento HTML. En este caso, scripts e imágenes. Con estos datos podemos obtener mucha información de la web que estamos analizando. Sin ir más lejos, con el penúltimo enlace de tipo <script> ya sabemos que mi blog utiliza el tema hemingway en su versión 1.74, que Google es el proveedor de anuncios e incluso, si quisiéramos, podríamos automáticamente descargar las fotos de esta web. ¿Conocéis el caso de Facemash, aquella “travesura” del creador de Facebook? Pues acabamos de ver que obtener las imágenes de un sitio web no es, para nada, algo difícil.
  • Imports: todas las importaciones (generalmente en el head). Vemos, por ejemplo, el nombre de la hoja de estilos del plugin simple code highlihter, el plugin que utilizo para insertar código fuente en las entradas.
  • Links: estos son los enlaces. Hacia fuera o dentro del sitio. Cuando añades un hipervínculo como este, estás insertando un <a href=”misitio.com”> que, por supuesto, jsoup sabe interpretar y así, podemos ver todas las URLs de una web. Los títulos de las entradas tienen un link al artículo completo, Amazon tiene, en cada artículo, un link a la página propia del artículo. Además, en cada artículo, puedes acceder a más artículos por la sección de recomendados que aparece debajo. Si accedemos de forma recursiva a estos enlaces, ¿te das cuenta de todo lo que podemos hacer? Bienvenido al web scraping.

Estos tipos obtenidos no son, ni mucho menos, los únicos que podemos obtener. Ahora nos estamos centrando solo en los links de un documento, que los necesitaremos para nuestro crawler, pero podemos obtener absolutamente cualquier tipo de elemento, buscar texto, etc.

¿Qué hace un crawler?

Un crawler analiza la información de un sitio web. También se conocen como arañas (spyder) o bots. Accede a una URL y hace una petición, como la que hemos hecho nosotros. Esta dirección puede venir dada de base como directorio raíz o puede haber llegado desde otra. Tras analizar el resultado, busca nuevas URLs en el documento obtenido y accede, de forma recursiva, a cada una de ellas. De esta forma, podemos explorar el contenido completo de un sitio web obteniendo todas sus direcciones. Es decir, todas las URLs que enlazan a absolutamente todo su contenido. Es muy potente y, a la vez, muy simple (si se hace bien). Evidentemente, según el sitio que queramos analizar y el tipo de información que queramos recuperar, habrá que aplicar diferentes directrices al algoritmo.

Siempre que hablo del web scraping, lo describo como un arte. No se trata solamente de un simple algoritmo que extrae datos. Es muy importante saber qué información queremos obtener, cómo está formateada en el sitio, cómo se organiza y cómo poder recorrerlo entero evitando el contenido que no nos interesa.

Es por esto que existen muchísimas plataformas de web scraping ofreciendo estos servicios. Existen, y cada vez más comunes, los sitios DAAS (Data As A Service). Muchos de ellos llaman a estos servicios como Datafiniti. Se trata de ahorrarte precisamente lo tedioso del scraping. Ellos tienen sus bots analizando infinidad de sitios web y tú, mediante unas simples llamadas a APIs, puedes obtener los datos que quieres estructurados adecuadamente bajo estándares como JSON. Algunos ejemplos de estos servicios son ScraperAPI, ParseHub o Mozenda. Y existen muchísimos más.

Diseñando nuestro crawler

En nuestro sencillo ejemplo lo único que queremos es indexar de forma recursiva todas las URLs posibles de un sitio web determinado con nuestro propio crawler. Para ello, partiremos de una URL raíz. La filtraremos con Jsoup y obtendremos los enlaces que nos interesen. En nuestro caso, de los 3 tipos vistos antes, solo nos interesan los enlaces de tipo <a href….>, que son los que llevan a otras direcciones.

Eso sí, debemos tener en cuenta que un hipervínculo puede llevar a sitios externos. Por ejemplo, un artículo que recomienda un producto seguramente tenga un enlace al producto en Amazon. Y si no controlamos esto, nuestro crawler se irá hasta Amazon y empezará a analizar recursivamente todo Amazon. Y claro, Amazon es una web muy pequeña, ¿verdad?. Si llegamos a sitios como Amazon por error, podemos dar por sentado que comenzar a analizar recursivamente todos los enlaces del sitio es lo mismo que un bucle infinito. Así que, antes de acceder a cada enlace, debemos verificar que es un enlace perteneciente al sitio que deseamos analizar.

Filtrando solo los enlaces interesantes

Lo primero que vamos a hacer va a ser centrarnos en el tercer tipo de enlaces del ejemplo base. Los links. No nos interesan ni las importaciones ni los objetos media embebidos. Nuestro método listAllLinks queda ahora reducido a esto:

public void listAllLinks(String url) throws IOException {
        System.out.println("Parsing page " + url + "...");

        Document doc = Jsoup.connect(url).get();
        Elements links = doc.select("a[href]");

        print("\nLinks: (%d)", links.size());
        for (Element link : links) {
            print(" * a: <%s>  (%s)", link.attr("abs:href"), trim(link.text(), 35));
        }

    }

Implementación recursiva

Ahora le cambiamos el nombre (para mejorar la legibilidad, ya que ahora será recursivo). Además, nuestra clase ParserEngine ahora tendrá dos atributos:

private String baseUrl;
private ArrayList<String> urlList;

El primero es la raíz de nuestro scraping. Si analizamos el sitio miweb.com, la raíz es miweb.com y no debemos perderla de vista. Así podemos evitar que se vaya a otras webs e indexar URLs no deseadas. De esta forma evitamos el error anterior de irnos a sitios indeseados, ya que siempre verificaremos que nuestro sitio contentga baseUrl.

Ya que tenemos dos atributos, vamos a meterle también un constructor:

public ParserEngine(String baseUrl){
        this.baseUrl = baseUrl;
        this.urlList = new ArrayList<String>();
    }

Nuestro método crawler quedaría algo así:

public void crawl(String url) throws IOException {
        Document doc = Jsoup.connect(url).get();
        Elements links = doc.select("a[href]");

        for (Element link : links) {
            String actualUrl = link.attr("abs:href");

            if (!urlList.contains(actualUrl) &
                actualUrl.startsWith(baseUrl)){

                print(" * a: <%s>  (%s)", actualUrl, trim(link.text(), 35));
                urlList.add(actualUrl);
                crawl(actualUrl);

            }
        }
    }

En esa sentencia if filtramos 2 cosas importantísimas:

  1. La URL que vamos a insertar no debe existir en la lista de URLs ya añadidas por nuestro crawler. En todas las webs es común que se repitan enlaces. Sobretodo al directorio raíz. Si estamos en un blog, todos los artículos en la cabecera tienen, normalmente, el título del sitio y, si haces click en él, te lleva a la página de inicio miblog.com. Por tanto, si no verificamos esto, accederemos infinitamente a la página de raíz, ya que la página raíz también tiene, en la primera posición a la que accedemos, la misma cabecera con el enlace a la página de inicio. Si no incluímos esto sólo veremos la web raíz infinitamente.
  2. Debe comenzar con la dirección raíz. Lo explicado antes, para evitar irnos, por ejemplo, a Amazon.

Main para ejecución

Tras tener claro todo esto, podemos escribir nuestro main creando una instancia de ParserEngine y ejecutando nuestro crawler:

public static void main(String[] args) throws IOException {
        String url = "http://elfreneticoinformatico.com";
        ParserEngine parser = new ParserEngine(url);
        parser.crawl(url);
        System.out.println("Crawler finished. Total URLs: " + parser.getUrlList().size());
    }

Ejecutamos y vemos como va avanzando en la recolección de enlaces.

Problemas con el tipo de contenido

Es común en los blogs hechos en WordPress que las imágenes tengan un link a sí mismas. Es decir, que al hacer click sobre ellas accedas a una URLs paginaweb.com/imagen.jpg.

A la hora de indexar todas las URLs de una web con nuestro propio crawler, esto puede presentar un problema porque, visto desde el documento, se trata de un <a href=»enlace a la imagen»> pero luego intenta leer el contenido de esa dirección y se encuentra que no es un HTML, sino una imagen. Nos dará un error como este:

«Unhandled content type. Must be text/*, application/xml, or application/xhtml+xml»

Estos enlaces deben recibir un tratamiento especial pero, para no alargarnos más (que ya es suficiente) simplemente le indicaremos a la conexión que ignore el tipo de documento que está parseando. De esta forma no dará error. Cuando lea el contenido de ese enlace, no añadirá nada a la lista de elementos y el bucle accederá a una lista vacía. Como es recursivo, pasará a la siguiente lista que no esté vacía de la iteración anterior.

Hacer esto es muy sencillo. Sólo debemos cambiar la línea donde instanciamos el objeto Document añadiendo el método para ignorar el tipo de contenido:

Document doc = Jsoup.connect(url).ignoreContentType(true).get();

Ejecución exitosa

Tras un tiempo, habremos recuperado todas las direcciones de un sitio web. En este caso mi blog personal es un sitio pequeño, idóneo para este tipo de ejemplos. Cuando ya ha recorrido todas las páginas de forma recursiva nos mostrará el siguiente resultado:

Felicidades: si obtienes un resultado similar, habrás conseguido indexar todas las URLs del sitio web deseado, !y con tu propio crawler!

Conclusiones

Ahora ya tenemos en una lista TODAS las URLs de un sitio. ¿Quieres saber qué paginas contienen una palabra? Puedes hacerlo. ¿Quieres analizar el número de palabras de cada página? También puedes. ¿Quieres saber cuántos enlaces a sitios externos hay en total, o por cada página? Efectivamente, también puedes. Ahora, con el crawler, tenemos acceso controlado a TODAS las páginas del sitio. Hemos conseguido indexar todas las URLs de una web con nuestro propio crawler. Lo siguiente es centrarte en buscar lo que quieres, utilizando esta lista de páginas como fuente.

Con el tiempo se irá incrementando el número de enlaces, así que es conveniente cada cierto tiempo reactualizar el listado volviendo a ejecutar el crawler.

Otras consideraciones

Otra razón que me he callado por la que he decidido utilizar mi blog personal es porque no tengo ninguna clase de firewall anti-bots. En muchos sitios (amazon, por ejemplo) si empiezas a analizar recursivamente todas las URLs el servidor te echará para evitar sobrecargar sus servidores a base de bots. En ese caso lo normal es pausar la ejecución un tiempo aleatorio. Por ejemplo, entre 5 y 20 segundos. Lo malo de eso es que va a retrasar mucho el proceso, así que ten mucha paciencia.

1 COMENTARIO

  1. Que buen post! Pero tengo una duda existencial… Corrí tu programa en mi IDE, tal como lo tenés vos y el resultado fue:
    «Parsing page http://elfreneticoinformatico.com
    Crawler finished. Total URLs: 0″
    Alguna explicación lógica para esto? Me sería muy útil tu respuesta, ya que estoy aprendiendo a hacer esto. Y aún hay cosas que no las comprendo muy bien como para saber con certeza porque a mi me dio 0 y a ti te dio otro numero.
    Gracias!

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad