Volver al blog
Guías
Mihai MaximLast updated on Mar 31, 20267 min read

Guía para principiantes sobre el web scraping con Rust

Guía para principiantes sobre el web scraping con Rust

¿Es Rust adecuado para el web scraping?

Rust es un lenguaje de programación diseñado para ofrecer velocidad y eficiencia. A diferencia de C o C++, Rust cuenta con un gestor de paquetes y una herramienta de compilación integrados. También dispone de una excelente documentación y un compilador intuitivo con mensajes de error útiles. Lleva un tiempo acostumbrarse a la sintaxis. Pero una vez que lo hagas, te darás cuenta de que puedes escribir funcionalidades complejas con solo unas pocas líneas de código. El web scraping con Rust es una experiencia enriquecedora. Tendrás acceso a potentes bibliotecas de scraping que te ahorran la mayor parte del trabajo pesado. Como resultado, podrás dedicar más tiempo a las partes divertidas, como diseñar nuevas funcionalidades. En este artículo, te guiaré a través del proceso de creación de un scraper web con Rust. 

Cómo instalar Rust

Instalar Rust es un proceso bastante sencillo. Visita Instalar Rust - Lenguaje de programación Rust (rust-lang.org) y sigue el tutorial recomendado para tu sistema operativo. La página muestra diferentes contenidos según el sistema operativo que estés utilizando. Al final de la instalación, asegúrate de abrir un terminal nuevo y ejecutar rustc --version. Si todo ha salido bien, deberías ver el número de versión del compilador de Rust instalado.

Dado que vamos a crear un rastreador web, vamos a crear un proyecto de Rust con Cargo. Cargo es el sistema de compilación y gestor de paquetes de Rust. Si has utilizado los instaladores oficiales proporcionados por rust-lang.org, Cargo ya debería estar instalado. Comprueba si Cargo está instalado introduciendo lo siguiente en tu terminal: cargo --version. Si ves un número de versión, ¡lo tienes! Si ves un error, como «comando no encontrado», consulta la documentación de tu método de instalación para determinar cómo instalar Cargo por separado. Para crear un proyecto, ve a la ubicación deseada y ejecuta cargo new <nombre del proyecto>.

Esta es la estructura predeterminada del proyecto:

Creación de un rastreador web con Rust

Ahora veamos cómo se puede usar Rust para crear un rastreador. El primer paso es definir un objetivo claro. ¿Qué quiero extraer? El siguiente es decidir cómo quieres almacenar los datos extraídos. La mayoría de la gente los guarda como .json, pero, en general, debes considerar el formato que mejor se adapte a tus necesidades particulares. Una vez aclarados estos dos requisitos, puedes seguir adelante con confianza con la implementación de cualquier rastreador. Para ilustrar mejor este proceso, propongo que creemos una pequeña herramienta que extraiga datos sobre la COVID-19 del sitio web COVID Live - Coronavirus Statistics - Worldometer (worldometers.info). Debería analizar las tablas de casos notificados y almacenar los datos como .json. Crearemos este rastreador juntos en los siguientes capítulos.

Obtención de HTML con solicitudes HTTP

Para extraer las tablas, primero tendrás que obtener el HTML que hay dentro de la página web. Usaremos el crate/biblioteca «reqwest» para obtener el HTML sin procesar del sitio web.

En primer lugar, añádela como dependencia en el archivo Cargo.toml:

reqwest = { version = "0.11", features = ["blocking", "json"] }

A continuación, define tu URL de destino y envía tu solicitud:

let url = "https://www.worldometers.info/coronavirus/";
let response = reqwest::blocking::get(url).expect("Could not load url.");

La función «blocking» garantiza que la solicitud sea sincrónica. Como resultado, el programa esperará a que se complete y luego continuará con las demás instrucciones. 

let raw_html_string = response.text().unwrap();

Uso de selectores CSS para localizar datos

Ya tienes todos los datos sin procesar necesarios. Ahora tienes que encontrar la manera de localizar las tablas de casos notificados. La biblioteca de Rust más popular para este tipo de tareas se llama «scraper». Permite el análisis de HTML y la consulta con selectores CSS.

Añade esta dependencia a tu archivo Cargo.toml:

scraper = "0.13.0"

Añade estos módulos a tu archivo main.rs.

use scraper::Selector;
use scraper::Html;

Ahora utiliza la cadena HTML sin procesar para crear un fragmento HTML:

let html_fragment = Html::parse_fragment(&raw_html_string);

Seleccionaremos las tablas que muestran los casos notificados de hoy, ayer y hace dos días.

Abre la consola de desarrollador e identifica los ID de las tablas:

En el momento de escribir este artículo, el identificador de hoy es: «main_table_countries_today».

Los otros dos ID de tabla son: «main_table_countries_yesterday» y «main_table_countries_yesterday2»

Ahora definamos algunos selectores:

let table_selector_string = "#main_table_countries_today, #main_table_countries_yesterday, #main_table_countries_yesterday2";

let table_selector = Selector::parse(table_selector_string).unwrap();

let head_elements_selector = Selector::parse("thead>tr>th").unwrap();
 
let row_elements_selector = Selector::parse("tbody>tr").unwrap();
 
let row_element_data_selector = Selector::parse("td, th").unwrap();

Pasa table_selector_string al método select de html_fragment para obtener las referencias de todas las tablas:

let all_tables = html_fragment.select(&table_selector);

Utilizando las referencias de las tablas, crea un bucle que analice los datos de cada tabla.

for table in all_tables{
   let head_elements = table.select(&head_elements_selector);
    for head_element in head_elements{
        //parse the header elements
    }

   let head_elements = table.select(&head_elements_selector);
   for row_element in row_elements{
    for td_element in row_element.select(&row_element_data_selector){
        //parse the individual row elements
    }
   }
}

Analizar los datos

El formato en el que se almacenan los datos determina la forma de analizarlos. Para este proyecto, es .json. Por lo tanto, debemos organizar los datos de la tabla en pares clave-valor. Podemos utilizar los nombres de las cabeceras de la tabla como claves y las filas de la tabla como valores. 

Utiliza la función .text() para extraer los encabezados y almacenarlos en un Vector:

//for table in tables loop
let mut head:Vec<String> = Vec::new();

let head_elements = table.select(&head_elements_selector);

for head_element in head_elements{
    let mut element = head_element.text().collect::<Vec<_>>().join(" ");
    element = element.trim().replace("\n", " ");
    head.push(element);
}


//head
["#", "Country, Other", "Total Cases", "New Cases", "Total Deaths", ...]

Extrae los valores de las filas de manera similar:

//for table in tables loop
let mut rows:Vec<Vec<String>> = Vec::new();

let row_elements = table.select(&row_elements_selector);

for row_element in row_elements{
 let mut row = Vec::new();
 for td_element in row_element.select(&row_element_data_selector){
     let mut element = td_element.text().collect::<Vec<_>>().join(" ");
     element = element.trim().replace("\n", " ");
     row.push(element);
 }
 rows.push(row)

}
//rows
[...
["", "World", "625,032,352", "+142,183", "6,555,767", ...]
...
["2", "India", "44,604,463", "", "528,745", ...]
...]

Utiliza la función zip() para crear una correspondencia entre los valores de los encabezados y las filas:

for row in rows {
    let zipped_array = head.iter().zip(row.iter()).map(|(a, b)|                        
    (a,b)).collect::<Vec<_>>();
 }

//zipped_array
[
...
[("#", ""), ("Country, Other", "World"), ("Total Cases", "625,032,352"), ("New Cases", "+142,183"), ("Total Deaths", "6,555,767"), ...]
...
]

Ahora almacena los pares (clave, valor) del zipped_array en un IndexMap:

serde = {version="1.0.0",features = ["derive"]}

indexmap = {version="1.9.1", features = ["serde"]} (add these dependencies)

use indexmap::IndexMap;

//use this to store all the IndexMaps 
let mut table_data:Vec<IndexMap<String, String>> = Vec::new();
for row in rows {
    let zipped_array = head.iter().zip(row.iter()).map(|(a, b)|                        
    (a,b)).collect::<Vec<_>>();
    let mut item_hash:IndexMap<String, String> = IndexMap::new();
    for pair in zipped_array{
        //we only want the non empty values
        if !pair.1.to_string().is_empty(){
            item_hash.insert(pair.0.to_string(), pair.1.to_string());
        }
    }
table_data.push(item_hash);

//table_data
[
...
{"Country, Other": "North America", "Total Cases": "116,665,220", "Total Deaths": "1,542,172", "Total Recovered": "111,708,347", "New Recovered": "+2,623", "Active Cases": "3,414,701", "Serious, Critical": "7,937", "Continent": "North America"}
,
{"Country, Other": "Asia", "Total Cases": "190,530,469", "New Cases": "+109,009", "Total Deaths": "1,481,406", "New Deaths": "+177", "Total Recovered": "184,705,387", "New Recovered": "+84,214", "Active Cases": "4,343,676", "Serious, Critical": "10,640", "Continent": "Asia"}
...
]

IndexMap es una excelente opción para almacenar los datos de la tabla, ya que conserva el orden de inserción de los pares (clave, valor).

Serialización de los datos

Ahora que puedes crear objetos similares a JSON con datos de tabla, es el momento de serializarlos a .json. Antes de empezar, asegúrate de tener instaladas todas estas dependencias:

serde = {version="1.0.0",features = ["derive"]}
serde_json = "1.0.85"
indexmap = {version="1.9.1", features = ["serde"]}

Almacena cada table_data en un vector tables_data:

let mut tables_data: Vec<Vec<IndexMap<String, String>>> = Vec::new();

For each table:
    //fill table_data (see previous chapter)
    tables_data.push(table_data);

Define un contenedor de estructura para tables_data:

 #[derive(Serialize)]
  struct FinalTableObject {
         tables: IndexMap<String, Vec<IndexMap<String, String>>>,
 }

Instancia la estructura:

let final_table_object = FinalTableObject{tables: tables_data};

Serializa la estructura en una cadena .json:

let serialized = serde_json::to_string_pretty(&final_table_object).unwrap();

Escribe la cadena .json serializada en un archivo .json:

use std::fs::File;
use std::io::{Write};

let path = "out.json";

    let mut output = File::create(path).unwrap();

    let result = output.write_all(serialized.as_bytes());

    match result {

          Ok(()) => println!("Successfully wrote to {}", path),

          Err(e) => println!("Failed to write to file: {}", e),

    }

Y ya está. Si todo ha salido bien, tu archivo .json de salida debería tener este aspecto:

{
  "tables": [
    [ //table data for #main_table_countries_today
      { 
       "Country, Other": "North America",
       "Total Cases": "116,665,220",
       "Total Deaths": "1,542,172",
       "Total Recovered": "111,708,347",
       "New Recovered": "+2,623",
       "Active Cases": "3,414,701",
       "Serious, Critical": "7,937",
       "Continent": "North America"
      },
      ...
    ],
    [...table data for #main_table_countries_yesterday...],
    [...table data for #main_table_countries_yesterday2...],
  ]
}

Puedes encontrar el código completo del proyecto en [Rust][Un sencillo rastreador de <table>] (github.com)

Realizar ajustes para adaptarlo a otros casos de uso

Si me has seguido hasta aquí, probablemente te hayas dado cuenta de que puedes usar este scraper en otros sitios web. El scraper no está limitado a un número específico de columnas de tabla ni a una convención de nomenclatura concreta. Además, no depende de muchos selectores CSS. Así que no debería hacer falta mucho ajuste para que funcione con otras tablas, ¿verdad? Probemos esta teoría.

Necesitamos un selector para la etiqueta <table>.

Si class="wikitable sortable jquery-tablesorter", podrías cambiar el table_selector a:

let table_selector_string = ".wikitable.sortable.jquery-tablesorter";
let table_selector = Selector::parse(table_selector_string).unwrap();

Esta tabla tiene la misma estructura <thead> <tbody>, por lo que no hay razón para cambiar los demás selectores.

El scraper debería funcionar ahora. Probémoslo:

{
  "tables": []
}

El scraping web con Rust es divertido, ¿verdad? 

¿Cómo podría fallar esto? 

Profundicemos un poco más:

La forma más fácil de averiguar qué ha fallado es mirar el HTML que devuelve la solicitud GET:

let url = "https://en.wikipedia.org/wiki/List_of_countries_by_population_in_2010";


let response = reqwest::blocking::get(url).expect("Could not load url.");

et raw_html_string = response.text().unwrap();

let path = "debug.html";


let mut output = File::create(path).unwrap();

let result = output.write_all(raw_html_string.as_bytes());

El HTML devuelto por la solicitud GET es diferente del que vemos en el sitio web real. El navegador ofrece un entorno para que Javascript se ejecute y modifique el diseño de la página. En el contexto de nuestro scraper, obtenemos la versión sin modificar.

Nuestro table_selector no funcionó porque la clase «jquery-tablesorter» es inyectada dinámicamente por JavaScript. Además, se puede ver que la estructura <table> es diferente. Falta la etiqueta <thead>. Los elementos del encabezado de la tabla se encuentran ahora en el primer <tr> del <tbody>. Por lo tanto, serán capturados por el row_elements_selector.

Eliminar «jquery-tablesorter» del table_selector no es suficiente, también tenemos que gestionar el caso en el que falta <tbody>:

let table_selector_string = ".wikitable.sortable";

 if head.is_empty() {
    head=rows[0].clone();
    rows.remove(0);
 }// take the first row values as head if there is no <thead>

Ahora probemos de nuevo:

{
  "tables": [
    [
      {
        "Rank": "--",
        "Country / territory": "World",
        "Population 2010 (OECD estimate)": "6,843,522,711"
      },
      {
        "Rank": "1",
        "Country / territory": "China",
        "Population 2010 (OECD estimate)": "1,339,724,852",
        "Area (km 2 ) [1]": "9,596,961",
        "Population density (people per km 2 )": "140"
      },
      {
        "Rank": "2",
        "Country / territory": "India",
        "Population 2010 (OECD estimate)": "1,182,105,564",
        "Area (km 2 ) [1]": "3,287,263",
        "Population density (people per km 2 )": "360"
      },
      ...
     ]
]

¡Así está mejor!

Resumen

Espero que este artículo sirva de buena referencia para el web scraping con Rust. Aunque el rico sistema de tipos y el modelo de propiedad de Rust pueden resultar un poco abrumadores, no son en absoluto inadecuados para el web scraping. Dispones de un compilador intuitivo que te guía constantemente en la dirección correcta. También encontrarás mucha documentación bien redactada: El lenguaje de programación Rust - El lenguaje de programación Rust (rust-lang.org).

Crear un rastreador web no siempre es un proceso sencillo. Te enfrentarás a la renderización de JavaScript, bloqueos de IP, captchas y muchos otros contratiempos. En WebScraping API, te proporcionamos todas las herramientas necesarias para combatir estos problemas comunes. ¿Tienes curiosidad por saber cómo funciona? Puedes probar nuestro producto de forma gratuita en WebScrapingAPI - Producto. O puedes ponerte en contacto con nosotros en WebScrapingAPI - Contacto. ¡Estaremos encantados de responder a todas tus preguntas!

Acerca del autor
Mihai Maxim, Desarrollador Full Stack @ WebScrapingAPI
Mihai MaximDesarrollador Full Stack

Mihai Maxim es desarrollador full stack en WebScrapingAPI, donde colabora en todas las áreas del producto y ayuda a crear herramientas y funciones fiables para la plataforma.

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.