Voltar ao blogue
Guias
Mihai MaximLast updated on Mar 31, 20267 min read

Guia para principiantes sobre web scraping com Rust

Guia para principiantes sobre web scraping com Rust

O Rust é uma boa opção para a extração de dados da Web?

O Rust é uma linguagem de programação concebida para oferecer velocidade e eficiência. Ao contrário do C ou do C++, o Rust possui um gestor de pacotes e uma ferramenta de compilação integrados. Dispõe também de uma excelente documentação e de um compilador intuitivo com mensagens de erro úteis. Leva algum tempo a habituar-se à sintaxe. Mas, assim que o fizer, vai perceber que consegue escrever funcionalidades complexas com apenas algumas linhas de código. O web scraping com o Rust é uma experiência enriquecedora. Tem acesso a poderosas bibliotecas de scraping que fazem a maior parte do trabalho pesado por si. Como resultado, pode dedicar mais tempo às partes divertidas, como a conceção de novas funcionalidades. Neste artigo, vou guiá-lo pelo processo de criação de um web scraper com Rust. 

Como instalar o Rust

A instalação do Rust é um processo bastante simples. Visite Install Rust - Rust Programming Language (rust-lang.org) e siga o tutorial recomendado para o seu sistema operativo. A página apresenta conteúdos diferentes consoante o sistema operativo que estiver a utilizar. No final da instalação, certifique-se de que abre um terminal novo e executa o comando rustc --version. Se tudo correu bem, deverá ver o número da versão do compilador Rust instalado.

Uma vez que vamos criar um web scraper, vamos criar um projeto Rust com o Cargo. O Cargo é o sistema de compilação e gestor de pacotes do Rust. Se utilizou os instaladores oficiais fornecidos por rust-lang.org, o Cargo já deverá estar instalado. Verifique se o Cargo está instalado digitando o seguinte no seu terminal: cargo --version. Se vir um número de versão, está instalado! Se vir um erro, como «comando não encontrado», consulte a documentação do seu método de instalação para determinar como instalar o Cargo separadamente. Para criar um projeto, navegue até à localização desejada para o projeto e execute cargo new <nome do projeto>.

Esta é a estrutura padrão do projeto:

  •   Escreve-se o código em ficheiros .rs.
  •   Gerencia as dependências no ficheiro Cargo.toml.
  •   Visite crates.io: Registo de Pacotes Rust para encontrar pacotes para Rust.

Criar um scraper web com Rust

Agora, vamos ver como pode usar o Rust para criar um scraper. O primeiro passo é definir um objetivo claro. O que pretendo extrair? O passo seguinte é decidir como pretende armazenar os dados extraídos. A maioria das pessoas guarda-os como .json, mas, em geral, deve considerar o formato que melhor se adapta às suas necessidades individuais. Com estes dois requisitos definidos, pode avançar com confiança para a implementação de qualquer scraper. Para ilustrar melhor este processo, proponho que criemos uma pequena ferramenta que extraia dados sobre a Covid do site COVID Live - Coronavirus Statistics - Worldometer (worldometers.info). Deve analisar as tabelas de casos relatados e armazenar os dados como .json. Iremos criar este scraper em conjunto nos capítulos seguintes.

Recuperação de HTML com pedidos HTTP

Para extrair as tabelas, terá primeiro de obter o HTML que se encontra dentro da página web. Iremos utilizar a crate/biblioteca “reqwest” para obter o HTML bruto do site.

Primeiro, adicione-a como dependência no ficheiro Cargo.toml:

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

Em seguida, defina o seu URL de destino e envie a sua solicitação:

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

A funcionalidade «blocking» garante que a solicitação é síncrona. Como resultado, o programa irá aguardar que esta seja concluída e, em seguida, continuará com as outras instruções. 

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

Usar seletores CSS para localizar dados

Já tem todos os dados brutos necessários. Agora tem de encontrar uma forma de localizar as tabelas de casos notificados. A biblioteca Rust mais popular para este tipo de tarefa chama-se “scraper”. Permite a análise de HTML e a realização de consultas com seletores CSS.

Adicione esta dependência ao seu ficheiro Cargo.toml:

scraper = "0.13.0"

Adicione estes módulos ao seu ficheiro main.rs.

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

Agora, use a string HTML bruta para criar um fragmento HTML:

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

Vamos selecionar as tabelas que mostram os casos notificados de hoje, ontem e há dois dias.

Abra a consola de programador e identifique os IDs das tabelas:

No momento da redação deste artigo, o ID para hoje é: “main_table_countries_today”.

Os outros dois IDs das tabelas são: “main_table_countries_yesterday” e “main_table_countries_yesterday2”

Agora vamos definir alguns seletores:

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();

Passe a string table_selector_string para o método select do html_fragment para obter as referências de todas as tabelas:

let all_tables = html_fragment.select(&table_selector);

Usando as referências das tabelas, crie um loop que analise os dados de cada tabela.

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
    }
   }
}

Analisar os dados

O formato em que os dados são armazenados determina a forma como são analisados. Para este projeto, é .json. Consequentemente, precisamos de colocar os dados da tabela em pares chave-valor. Podemos usar os nomes dos cabeçalhos da tabela como chaves e as linhas da tabela como valores. 

Use a função .text() para extrair os cabeçalhos e armazená-los num 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", ...]

Extraia os valores das linhas de forma semelhante:

//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", ...]
...]

Utilize a função zip() para criar uma correspondência entre os valores dos cabeçalhos e das linhas:

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"), ...]
...
]

Agora armazene os pares zipped_array (chave, valor) num 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"}
...
]

O IndexMap é uma excelente escolha para armazenar os dados da tabela, pois preserva a ordem de inserção dos pares (chave, valor).

Serialização dos dados

Agora que já consegue criar objetos semelhantes a JSON com dados da tabela, é hora de os serializar para .json. Antes de começarmos, certifique-se de que tem todas estas dependências instaladas:

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

Armazene cada table_data num 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);

Defina um contêiner de estrutura para o tables_data:

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

Instancie a estrutura:

let final_table_object = FinalTableObject{tables: tables_data};

Serialize a estrutura para uma string .json:

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

Grave a string .json serializada num ficheiro .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),

    }

E pronto. Se tudo correu bem, o seu ficheiro .json de saída deverá ter o seguinte aspeto:

{
  "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...],
  ]
}

Pode encontrar o código completo do projeto em [Rust][Um simples <table> scraper] (github.com)

Fazer ajustes para se adequar a outros casos de uso

Se me acompanhou até aqui, provavelmente já percebeu que pode usar este scraper noutros sites. O scraper não está limitado a um número específico de colunas da tabela ou a uma convenção de nomenclatura. Além disso, não depende de muitos seletores CSS. Por isso, não deve ser preciso fazer muitos ajustes para que funcione noutras tabelas, certo? Vamos testar esta teoria.

Precisamos de um seletor para a tag <table>.

Se class="wikitable sortable jquery-tablesorter", pode alterar o table_selector para:

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

Esta tabela tem a mesma estrutura <thead> <tbody>, pelo que não há motivo para alterar os outros seletores.

O scraper deve funcionar agora. Vamos fazer um teste:

{
  "tables": []
}

O webscraping com Rust é divertido, não é? 

Como é que isto poderia falhar? 

Vamos aprofundar um pouco mais:

A maneira mais fácil de descobrir o que correu mal é olhar para o HTML que é devolvido pela solicitação 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());

O HTML devolvido pela solicitação GET é diferente daquele que vemos no site real. O navegador oferece um ambiente para o Javascript ser executado e alterar o layout da página. No contexto do nosso scraper, obtemos a versão inalterada da mesma.

O nosso table_selector não funcionou porque a classe “jquery-tablesorter” é injetada dinamicamente pelo Javascript. Além disso, pode ver-se que a estrutura <table> é diferente. Falta a tag <thead>. Os elementos do cabeçalho da tabela encontram-se agora no primeiro <tr> do <tbody>. Assim, serão capturados pelo row_elements_selector.

Remover “jquery-tablesorter” do table_selector não é suficiente; também precisamos de lidar com o caso em que falta o <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>

Agora vamos tentar outra vez:

{
  "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"
      },
      ...
     ]
]

Assim está melhor!

Resumo

Espero que este artigo seja um bom ponto de referência para a extração de dados da Web com Rust. Embora o rico sistema de tipos e o modelo de propriedade do Rust possam ser um pouco intimidantes, ele não é de forma alguma inadequado para a extração de dados da Web. Você conta com um compilador intuitivo que constantemente o orienta na direção certa. Também encontrará muita documentação bem escrita: The Rust Programming Language - The Rust Programming Language (rust-lang.org).

Construir um web scraper nem sempre é um processo simples. Irá deparar-se com renderização em Javascript, bloqueios de IP, captchas e muitos outros obstáculos. Na WebScraping API, fornecemos-lhe todas as ferramentas necessárias para combater estes problemas comuns. Está curioso para saber como funciona? Pode experimentar o nosso produto gratuitamente em WebScrapingAPI - Produto. Ou pode contactar-nos em WebScrapingAPI - Contacto. Teremos todo o prazer em responder a todas as suas perguntas!

Sobre o autor
Mihai Maxim, Desenvolvedor Full Stack @ WebScrapingAPI
Mihai MaximDesenvolvedor Full Stack

Mihai Maxim é um programador Full Stack na WebScrapingAPI, contribuindo em todas as áreas do produto e ajudando a criar ferramentas e funcionalidades fiáveis para a plataforma.

Comece a construir

Pronto para expandir a sua recolha de dados?

Junte-se a mais de 2.000 empresas que utilizam a WebScrapingAPI para extrair dados da Web à escala empresarial, sem quaisquer custos de infraestrutura.