Scraper in R per il Meteo.it

Beniamino Sartini

20/06/2022

Le Fonti dei Dati

I dati metereologici sono una tipologia di dati difficilemnte repereibili velocemente su internet. Tra le fonti piu autorevoli presenti citiamo:

  • Sul sito dell’ aereonautica militare, è possibile ottenere dati di diverso tipo, tuttavia non sono ad accesso libero, ma a **pagamento*.

  • Gli open data della pubblica amministrazione, contengono numerose serie storiche per diversi argomenti oltre ai dati metereologici. Tuttavia i dati riportati non sono omogenei e sono spesso inconsistenti in termini di formato tra i vari comuni/regioni che li riportano. Inoltre non è possibile ottenere serie omogenee per tutte le citta/province italiane, ma solo per alcune rendendo l’analisi potenziale comunque parziale in partenza.

Archivio Storico del Meteo.it

L’unico sito italiano che presenta delle serie storiche complete e con numerose variabili riportate è il ilmeteo.it, sezione archivio storico. Come riportato nel sito uffiale le misurazioni sono prese dalle stazioni di ufficiali, i cui dati sono riportati anche negli altri siti. Una differenza che abbiamo trovato è con i dati di Copernicus, ad esempio, è la temperatura media: mentre nei dati di Copernicus spesso è calcolata come \(\frac{min + max}{2}\) mentre nel meteo.it non è sempre cosi.

Costruzione dello scaper

In primo luogo notiamo che l’url per accedere all’archivio presenta una certa regolarità:

  • ilmeteo.it/portale/archivio-meteo/, {citta}/{anno}/{mese}.

Possiamo quindi procedere ad individuare un framework consistente e robusto, per farlo procederemo con uno schema top-down per capire la struttura del sito, per poi implementare un framework bottom-up.

Le informazioni disponibili

Abbiamo notato che esistono due livelli di informazione disponibili:

  1. Il primo livello è accessibile tramite un url del tipo {citta}/{anno}/{mese}, tuttavia questo livello, facile da raggiungere e importare contiene solo una parte delle informazioni disponibili.

  2. Un secondo livello del tipo {citta}/{anno}/{mese}/{giorno}, contine molte più informazioni tra cui:

  • temperatura (minima, massima e media)
  • punto di rugiada (dew_point)
  • precipitazioni
  • umidità (minima, massima e media)
  • visibilità
  • velocita del vento (massima e media) e raffiche
  • pressione (media e a livello del mare)
  • fenomeni atmosferici del giorno (etichette del meteo.it)
  • condizioni meteo del giorno (etichette del meteo.it)
# url di esempio 
url = "https://www.ilmeteo.it/portale/archivio-meteo/Genova/2020/Febbraio"

# importazione html 
html = xml2::read_html(url)

# le informazioni storiche si trovano nella tabella 4 
table = xml2::xml_find_all(html, "//table")[4]

# ricerco tutti i tr contenuti nella 4 tabella (i tr rappresentano la singola riga della tabella)
all_tr = xml2::xml_find_all(table, "./tr")

Livello 0: tr_to_row_meteoIT

Funzione tr_to_row, è la funzione di base che prende in input un singolo tr dalla lista all_tr e restituisce una riga di una tabella ordinata. Abbiamo optato per questa divisione poiche in questa maniera strutturiamo a priori il formato della tabella che vogliamo avere in output, siamo in grado di controllare ed evitare errori qualora un elemento non venga trovato e in generale rendiamo la procedura facilemente modificabile qualora avvenga una modifica nei codici sorgenti.

# FUNCTION: NULL_df_meteoIT 
# output: tabella base per importazione 

NULL_df_meteoIT <- function(){
  
  # base dataset for importation 
  dplyr::tibble(
    
    date  = NA_character_,  # data in formato %YYYY/%MM/%DD
    city  = NA_character_,  # nome citta
    n.day = NA_character_,  # numero giorno del mese 
    month = NA_character_,  # numero del mese 
    year  = NA_character_,  # anno 
    
    mean_temp = NA_integer_,  # temperatura media 
    min_temp  = NA_integer_,  # temperatura minima 
    max_temp  = NA_integer_,  # temperatura massima 
    
    dew_point  = NA_character_,  # punto di rugiada
    precip     = NA_character_,  # precipitazioni 
    mean_humid = NA_character_,  # umidita media 
    min_humid  = NA_character_,  # umidita minima 
    max_humid  = NA_character_,  # umidita massima 
    visibility = NA_character_,  # visibilita
    mean_wind  = NA_character_,  # velocita vento media 
    max_wind   = NA_character_,  # velocita vento massima 
    burst      = NA_character_,     # raffiche di vento 
    mean_pressure     = NA_character_, # pressione media 
    sealevel_pressure = NA_character_, # pressione media sul livello del mare
    
    rain      = NA_character_,              # pioggia 
    phenomena = NA_character_,              # fenomeni metereologici
    weather_condition = NA_character_,      # condizioni meteo 
    url_info = NA_character_                # url infomazioni giornaliere 
  ) 
  
}

# FUNCTION: tr_to_row_meteoIT 
# tr: xml node della riga della tabella dell'archivio storico meteo.it 

tr_to_row_meteoIT <- function(tr = NULL){
  
  # initialize the data to have always an output
  null_df = NULL_df_meteoIT()
  
  all_td = xml2::xml_find_all(tr, "./td")
  
  n.day     = xml2::xml_text(all_td[1])
  mean_temp = xml2::xml_text(all_td[2])
  min_temp  = xml2::xml_text(all_td[3])
  max_temp  = xml2::xml_text(all_td[4])
  precip    = xml2::xml_text(all_td[5])
  max_vento = xml2::xml_text(all_td[7])
  url_info  = xml2::xml_attr(xml2::xml_find_all(all_td[10], "./a"), "href")
  
  null_df$n.day     = ifelse(is.null(n.day), NA_character_, n.day)
  null_df$mean_temp = ifelse(is.null(mean_temp), NA_character_, mean_temp)
  null_df$min_temp  = ifelse(is.null(min_temp), NA_character_, min_temp)
  null_df$max_temp  = ifelse(is.null(max_temp), NA_character_, max_temp)
  null_df$precip    = ifelse(is.null(precip), NA_character_, precip)
  null_df$max_wind  = ifelse(is.null(max_vento), NA_character_, max_vento)
  null_df$url_info  = ifelse(is.null(url_info), NA_character_, paste0("https://www.ilmeteo.it/portale/", url_info))
  
  return(null_df)
  
}


# FUNZIONI Utils 
# rende la prima lettera di una parola maiuscola (per controllo imput)
  FirstToupper <- function(word){
    
    word = str_split(word, "")
    
    word[[1]][1] = toupper(word[[1]][1])
    
    word = paste0(word[[1]], collapse = "")
    
    return(word)
    
  }  
  
  
# Function: xml_find_attr
# funzione modificata dal pacchetto xml2 che permette di estrarre direttamente gli elementi aventi un determinato 
# valore in un attributo (class, id, etc), è possibile selezionare un solo attributo ma piu nomi di classi possibili 

xml_find_attr <- function(x = NULL, xpath = NULL, attr.names = "", attr.type = "class", ns = xml2::xml_ns(x), exact_match = TRUE){
  
  all_elements = xml2::xml_find_all(x = x, xpath = xpath, ns = ns)
  
  if(exact_match){
    
    class_index = which(xml2::xml_attr(all_elements, attr.type) %in% attr.names)
    
  } else {
    
    class_index = which(stringr::str_detect(xml2::xml_attr(all_elements, attr.type), attr.names[1]))
    
  }
  
  all_elements[class_index]
  
}

Livello 1: city_month_year_MeteoIT

La funzione city_month_year_MeteoIT, è una funzione che prende in input il nome di una citta (city), il mese da importare (month) e l’anno (imp.year) e restituisce in output una singola tabella della forma del null_df.

city_month_year_MeteoIT <- function(city = NULL, month = NULL, year = NULL, verbose = TRUE){
  
  # URL di base 
  base_url = "https://www.ilmeteo.it/portale/archivio-meteo"
  
  # 1)  Argomento "anno"
  anni_validi = seq(1973, lubridate::year(Sys.Date()), 1) # anni ammessi 
  imp.year = match.arg(year, anni_validi)                 # anno di importazione 
  
  # 2) Argomento "citta"
  # la citta deve essere scritta sempre con la lettera maiuscola
  # le citta con un nome composto vengono separate dal "+": es Ascoli+Piceno
  
  city = stringr::str_split(city, " ")[[1]]    # separo le parole (se sono piu di una)
  city = purrr::map_chr(city, FirstToupper)     # trasformo tutte le lettere in maiuscole 
  city_name = paste0(city, collapse = " ")      # salvo il nome della citta 
  city = paste0(city, collapse = "+")           # ricreo la stringa 
  
  # 3) Argomento "mese"
  mesi_validi = c(Gennaio   = 1, Febbraio  = 2, Marzo     = 3,  Aprile   = 4, 
                  Maggio    = 5, Giugno    = 6,  Luglio   = 7,  Agosto   = 8, 
                  Settembre = 9, Ottobre   = 10, Novembre = 11, Dicembre = 12)
  
  # indice per il numero del mese 
  month_index = match.arg(month, mesi_validi)
  # nome del mese per comporre url 
  month.name  = names(month_index)             
  
  # 4) creazione url per l'importazione 
  url = paste0(base_url, "/", city, "/", imp.year, "/", month.name )
  
  # 5) lettura html 
  html = xml2::read_html(url) 
  
  # 6) tabella con informazioni di interesse: n°4
  table = xml2::xml_find_all(html, "//table")[4]
  
  # 7) separo tutte le righe per avere un importazione controllata (ma piu lunga) 
  all_tr = xml2::xml_find_all(table, "./tr")
  
  # 8) Mapping "tr_to_row_meteoIT"
  df_importazione = purrr::map_df(all_tr[-1], tr_to_row_meteoIT)
  
  # 9) Estrazione stazione di riferimento
  station = xml2::xml_text(xml_find_attr(html, xpath = ".//div", attr.type = "class", attr.names = "smalllinks")[1])
  station = stringr::str_remove_all(station, "Dati registrati dalla stazione meteo di  ")
  station = tm::removePunctuation(station)
  
  
  # aggiunta informazioni sulla citta, mese e anno 
  df_importazione = dplyr::mutate(df_importazione, 
                                  city = city_name, 
                                  month = month_index,
                                  year = imp.year, 
                                  station = station)
  
  # creazione della data
  df_importazione$date = as.Date(paste0(imp.year,"/", month_index, "/", df_importazione$n.day))
  
  if(verbose) message("Importazione: ", city_name, " (Anno: ", imp.year, ", Mese: ", month, ")")
  
  return(df_importazione)
  
}

Livello Intermedio: city_year_MeteoIT

Funzione city_year_MeteoIT, è la funzione analoga alla precedente, con l’unica differenza che permette di importare tutti i mesi disponibili, la funzione è stata creata con lo scopo di evitare di iterare tutti i mesi dell’anno qualora inserissimo l’anno corrente e per importare solo i mesi dall’inizio dell’anni qualora l’anno (year) corrisponda a quello corrente.

# FUNCTION: Meteo_city_year: map "city_month_year_MeteoIT" su un anno 
city_year_MeteoIT <- function(city = NULL, year = NULL, verbose = TRUE){
  
  # match argomento anni
  anni_validi = seq(1973, lubridate::year(Sys.Date()), 1)
  
  imp.year = as.character(match.arg(year, anni_validi))
  
  if(imp.year == lubridate::year(Sys.Date())){
    
    max_month = lubridate::month(Sys.Date())
    months = as.character(1:max_month)
    
  } else {
    
    months = as.character(1:12)
    
  }
  
  safe_city_month_year = purrr::safely(city_month_year_MeteoIT)
  
  purrr::map_df(months, ~safe_city_month_year(city    = city, 
                                              month   = .x, 
                                              year    = year, 
                                              verbose = verbose)$result)

}

Aggiunta delle Informazioni giornaliere

Molte delle informazioni presenti nella pagina principale dell’archivio sono incoplete o non presenti. Per poter ottenere tutte le informazioni si devono richiedere gli url per la pagina corrispondente al singolo giorno. La procedura è particolarmente lunga in termini di tempo (per 10 anni di dati completi circa 1.30 h), ma è l’unica procedura che permette di ottenere senza particolare sforzo delle serie storiche subito analizzabili senza dover effettuare operazioni manuali. Inoltre i dati presenti comprono una vasta gamma di informazioni permettendo diverse applicazioni.

# Aggiunta informazioni giornaliere 
add_daily_MeteoIT <- function(df = NULL, verbose = TRUE ){
  
  # inizializzo output 
  df_output = NULL_df_meteoIT()
  
  # funzione per importare le informazioni di un singolo url di una singola riga 
  DailyInfo <- function(df_row){
    
    url = df_row$url_info[1]
    
    html = xml2::read_html(url)
    
    table = xml2::xml_find_all(html, ".//table")[4]
    
    table = rvest::html_table(table)[[1]]
    
    df_row$mean_temp = table[1,][[2]]
    df_row$min_temp = table[2,][[2]]
    df_row$max_temp = table[3,][[2]]
    df_row$dew_point = table[4,][[2]]
    df_row$mean_humid = table[5,][[2]]
    df_row$min_humid = table[6,][[2]]
    df_row$max_humid = table[7,][[2]]
    df_row$visibility = table[8,][[2]]
    df_row$mean_wind = table[9,][[2]]
    df_row$max_wind = table[10,][[2]]
    df_row$burst = table[11,][[2]]
    df_row$mean_pressure = table[12,][[2]]
    df_row$sealevel_pressure = table[13,][[2]]
    df_row$rain = table[14,][[2]]
    df_row$phenomena = table[15,][[2]]
    df_row$weather_condition = table[16,][[2]]
    
    return(df_row)
    
  }
  
  n_iter <- nrow(df) # Number of iterations of the loop
  
  if(verbose) message("Tempo Stimato: ", round(n_iter), " Secondi")
  
  start_time = Sys.time()
  
  if(verbose){
    # Initializes the progress bar
    pb <- txtProgressBar(min = 0,      # Minimum value of the progress bar
                         max = n_iter, # Maximum value of the progress bar
                         style = 3,    # Progress bar style (also available style = 1 and style = 2)
                         width = 50,   # Progress bar width. Defaults to getOption("width")
                         char = "=")   # Character used to create the bar
  }
  
  
  all_df_info = list()
  
  safe_import = purrr::safely(DailyInfo)
  
  for(i in 1:n_iter) {
    
    result = safe_import(df[i,])$result
    # controllo per eventuali pagine mancanti 
    if(is.null(result)){
      all_df_info[[i]] = df[i,]
    } else {
      all_df_info[[i]] = result 
    }
    
    if(verbose){setTxtProgressBar(pb, i)} # settaggio progress bar 
    
  }
  
  if(verbose){close(pb)} # chiusura connesione 
  
  end_time = Sys.time()
  
  if(verbose) message("Tempo Impiegato: ", 
                      round(end_time-start_time, 3), 
                      " Minuti...")
  
  # dataset finale 
  df_output = dplyr::bind_rows(all_df_info)
  
  return(df_output)
  
}

Livello Finale: MeteoIT

Funzione MeteoIT, prende in input una citta, un anno di inizio ed uno di fine e importa tutti i dati creando una tabella unica.

MeteoIT <- function(city = NULL, start = "2021", end = "2022", verbose = TRUE, add_daily_info = FALSE, object.name = NULL){
  
  start = as.numeric(start)
  end = as.numeric(end)
  
  # creo la sequenza di anni 
  seq_years = seq(start, end, 1)
  seq_years = as.character(seq_years)
  
  safe_city_year = purrr::safely(city_year_MeteoIT)
  
  df_importazione = purrr::map_df(seq_years, ~safe_city_year(city, year = .x, verbose = verbose)$result)
  
  if(verbose){message("Importazione Parziale Completata!")}
  
  # se il salvataggio è attivo salviamo dopo la prima importazione 
  if(!is.null(object.name)){
    
    data = Sys.Date()
    
    data = stringr::str_replace_all(data, "-", "_")
    
    filename = paste0(object.name, "_", data, "_",  ".RData")
    
    assign(object.name, df_importazione)

    save(list = object.name, file = filename)
    
    if(verbose){message("Importazione Parziale dell'oggetto: <", object.name, "> salvata con il nome di: ", filename)}
    
  }
  

  if(add_daily_info){
    
    if(verbose){message("Importazione con informazioni giornaliere in corso...")}
    
    # aggiunta informazioni complete 
    df_completo = add_daily_MeteoIT(df_importazione, verbose = verbose)
    
    # df_completo = CleanMeteoIT(df_completo)
    
    # se il salvataggio è attivo salviamo tutto dopo la seconda importazione 
    
    # se il salvataggio è attivo salviamo dopo la prima importazione 
    if(!is.null(object.name)){
      
      data = Sys.Date()
      
      data = stringr::str_replace_all(data, "-", "_")
      
      filename = paste0(object.name, "_", data, "_",  ".RData")
      
      assign(object.name, df_completo)
      
      save(list = object.name, file = filename)
      
      if(verbose){message("Importazione completa dell'oggetto: <", object.name, "> salvata con il nome di: ", filename)}
      
    }
    
    return(df_completo)
  }
  
  return(df_importazione)

}

Esempio di Utilizzo

Importazione dei dati senza informazioni giornaliere:

df = MeteoIT(city = "Bologna", start = "2022", end = "2022", verbose = FALSE)

df[1:10, 1:11] %>% 
  knitr::kable(caption = "Esempio parziale di Output (senza informazioni giornaliere)") %>%
  kableExtra::kable_classic() %>%
  kable_styling(latex_options = "hold_position")
Esempio parziale di Output (senza informazioni giornaliere)
date city n.day month year mean_temp min_temp max_temp dew_point precip mean_humid
2022-01-01 Bologna 1 1 2022 2 °C -1 °C 4 °C NA
NA
2022-01-02 Bologna 2 1 2022 5 °C -2 °C 11 °C NA
NA
2022-01-03 Bologna 3 1 2022 4 °C 3 °C 5 °C NA
NA
2022-01-04 Bologna 4 1 2022 5 °C 3 °C 6 °C NA
NA
2022-01-05 Bologna 5 1 2022 7 °C 4 °C 16 °C NA n/d NA
2022-01-06 Bologna 6 1 2022 5 °C 2 °C 7 °C NA n/d NA
2022-01-07 Bologna 7 1 2022 3 °C 0 °C 7 °C NA
NA
2022-01-08 Bologna 8 1 2022 2 °C -1 °C 6 °C NA
NA
2022-01-09 Bologna 9 1 2022 0 °C -3 °C 3 °C NA n/d NA
2022-01-10 Bologna 10 1 2022 3 °C 1 °C 5 °C NA n/d NA

Una volta importata il primo strato si puo procedere ad aggiungere i dati giornalieri, utilizzando la funzione meteoIT è possibile farlo automaticamente impostando il parametro add_daily_info = TRUE altrimenti è possibile importare i dati manualmente dando in input il datset importato al punto precedente nel seguente modo:

add_daily_MeteoIT(df[1:10,], verbose = FALSE)[,12:24] %>% 
  select(-url_info, -sealevel_pressure, -burst, -station) %>%
  knitr::kable(caption = "Esempio parziale di Output (con informazioni giornaliere)") %>%
  kableExtra::kable_classic() %>%
  kable_styling(latex_options = "hold_position")
Esempio parziale di Output (con informazioni giornaliere)
min_humid max_humid visibility mean_wind max_wind mean_pressure rain phenomena weather_condition
100 % 100 % 1 km 4 km/h 9 km/h 1026 mb
Nebbia nebbia al mattino
76 % 100 % 11 km 5 km/h 11 km/h 1024 mb
Nebbia nebbia al mattino
100 % 100 %
5 km/h 9 km/h 1020 mb
Nebbia nebbia
100 % 100 %
5 km/h 11 km/h 1011 mb
Nebbia nebbia
63 % 100 % 5 km 12 km/h 24 km/h 1001 mb n/d Pioggia - Nebbia pioggia debole
81 % 100 % 17 km 11 km/h 24 km/h 1013 mb n/d Pioggia pioggia e schiarite
61 % 100 % 19 km 6 km/h 11 km/h 1019 mb
Nessuno sereno
65 % 100 % 17 km 6 km/h 11 km/h 1015 mb
Nessuno sereno
87 % 100 % 10 km 8 km/h 15 km/h 1006 mb n/d Pioggia - Neve poco nuvoloso
70 % 93 % 19 km 9 km/h 15 km/h 1011 mb n/d Pioggia sereno