Scraping de estadísticas de fútbol con `rvest`, `polite` y `purrr`.

En este post me gustaría introducir la potencialidad del web scraping a través rvest y de la programación funcional de la mano de purrr. Además de estos paquetes que he destacado, como siempre, me apoyaré en algún otro paquete de la familia Tidyverse, como dplyr y tidyr.

Para este propósito, voy a utilizar la completa web de FBREF, que provee estadísticas muy detalladas de equipos y jugadores de fútbol. Para aquellos que les guste la analítica avanzada en este deporte, en FBREF pueden encontrar métricas más complejas, como el xG. En este caso, el objetivo final es tener las estadísticas disponibles en FBREF para cada jugador de la Primera División de España.

En primer lugar, cargamos las librerías que se van a utilizar.

# load libraries
pkgs <- c("rvest", "polite", "dplyr", "tidyr", "purrr", "stringr", "glue", "rlang", "janitor")
invisible(lapply(pkgs, require, character.only = TRUE))

Como he dicho, este post tiene dos partes relevante: la del web scraping y la de la programación funcional. Para la primera de las partes nos apoyaremos en rvest, pero es muy importante señalar que la extracción de datos en sitios web se tiene que hacer de forma responsable. La forma correcta de hacerlo es conociendo los términos de uso, pero muchas webs también incorporan un fichero (robots.txt) que especifica los accesos para bots (como nosotros cuando hacemos scraping). Desafortunadamente, en muchos tutoriales que uno puede encontrarse para hacer web scraping tiende a obviar este importante aspecto. En definitiva, nuestras acciones sobre una determinada web pueden tener impacto sobre otros usuarios o sobre la propia web.

Hay un fantástico paquete en R llamado polite que se encarga de buscar este fichero robots.txt, extraer los permisos de acceso e indicarle a la web el agente que está realizando el scraping.

Lo primero que vamos a hacer es extraer la URL de todos los equipos de la Primera División. A partir de estas URL vamos a extraer las propias para cada jugador. Estas son las funciones que realizan ambas acciones.

get_teams <- function(url_league) {
  session <- bow(url_league,
                 user_agent = 'Scrapping FBREF tutorial')
  
  teams <-
    scrape(session) %>% 
    html_nodes(xpath = '//*[@id="results32391_overall"]/tbody/tr/td[1]/a')
  
  tibble(
    team = teams %>% html_text(),
    url_team = paste0("https://fbref.com", teams %>% html_attr("href"))
  )
}

get_player <- function(url_team) {
  session <- bow(url_team,
                 user_agent = 'Scrapping FBREF tutorial')
  
  players <-
    scrape(session) %>%  
    html_nodes(xpath = '//*[@id="stats_standard_3239"]/tbody/tr/th/a') 
  
  tibble(
    url_team = url_team,
    player = players %>% html_text(),
    url_player = paste0("https://fbref.com", players %>% html_attr("href"))
  )
}

Lo primero que se hace es leer la URL y, a partir de aquí, tenemos que buscar el nodo que recoge la información que estamos buscando. Esto se puede hacer inspeccionando el código fuente de la web y buscando el nodo de interés, como se puede ver en la siguiente captura.

Ahora, extraemos para cada equipo su URL y lo almacenamos en el data.frame all_teams. Con cada una de estas URL podemos acceder a las URL de cada jugador y para ello hacemos uso de purrr::map, que nos permite ir iterando sobre cada una de las URL de los equipos para extraer todas las URL de los jugadores. Con unas pocas líneas de código, aprovechando las bondades de la programación funcional con purrr, tenemos la llave para acceder a las estadísticas de los jugadores.

# Extraemos las URL de los equipos
all_teams <- get_teams("https://fbref.com/en/comps/12/La-Liga-Stats")
all_teams
## # A tibble: 20 x 2
##    team            url_team                                                  
##    <chr>           <chr>                                                     
##  1 Real Madrid     https://fbref.com/en/squads/53a2f082/Real-Madrid-Stats    
##  2 Barcelona       https://fbref.com/en/squads/206d90db/Barcelona-Stats      
##  3 Atlético Madrid https://fbref.com/en/squads/db3b9613/Atletico-Madrid-Stats
##  4 Sevilla         https://fbref.com/en/squads/ad2be733/Sevilla-Stats        
##  5 Villarreal      https://fbref.com/en/squads/2a8183b3/Villarreal-Stats     
##  6 Real Sociedad   https://fbref.com/en/squads/e31d1cd9/Real-Sociedad-Stats  
##  7 Granada         https://fbref.com/en/squads/a0435291/Granada-Stats        
##  8 Getafe          https://fbref.com/en/squads/7848bd64/Getafe-Stats         
##  9 Valencia        https://fbref.com/en/squads/dcc91a7b/Valencia-Stats       
## 10 Osasuna         https://fbref.com/en/squads/03c57e2b/Osasuna-Stats        
## 11 Athletic Club   https://fbref.com/en/squads/2b390eca/Athletic-Club-Stats  
## 12 Levante         https://fbref.com/en/squads/9800b6a1/Levante-Stats        
## 13 Valladolid      https://fbref.com/en/squads/17859612/Valladolid-Stats     
## 14 Eibar           https://fbref.com/en/squads/bea5c710/Eibar-Stats          
## 15 Betis           https://fbref.com/en/squads/fc536746/Real-Betis-Stats     
## 16 Alavés          https://fbref.com/en/squads/8d6fd021/Alaves-Stats         
## 17 Celta Vigo      https://fbref.com/en/squads/f25da7fb/Celta-Vigo-Stats     
## 18 Leganés         https://fbref.com/en/squads/7c6f2c78/Leganes-Stats        
## 19 Mallorca        https://fbref.com/en/squads/2aa12281/Mallorca-Stats       
## 20 Espanyol        https://fbref.com/en/squads/a8661628/Espanyol-Stats
# Extraemos las URL de los jugadores
all_players <- purrr::map_df(.x = all_teams$url_team, ~ get_player(.))
all_players
## # A tibble: 666 x 3
##    url_team                        player       url_player                      
##    <chr>                           <chr>        <chr>                           
##  1 https://fbref.com/en/squads/53… Karim Benze… https://fbref.com/en/players/70…
##  2 https://fbref.com/en/squads/53… Casemiro     https://fbref.com/en/players/4d…
##  3 https://fbref.com/en/squads/53… Sergio Ramos https://fbref.com/en/players/08…
##  4 https://fbref.com/en/squads/53… Thibaut Cou… https://fbref.com/en/players/18…
##  5 https://fbref.com/en/squads/53… Raphaël Var… https://fbref.com/en/players/9f…
##  6 https://fbref.com/en/squads/53… Dani Carvaj… https://fbref.com/en/players/49…
##  7 https://fbref.com/en/squads/53… Toni Kroos   https://fbref.com/en/players/6c…
##  8 https://fbref.com/en/squads/53… Luka Modrić  https://fbref.com/en/players/60…
##  9 https://fbref.com/en/squads/53… Federico Va… https://fbref.com/en/players/09…
## 10 https://fbref.com/en/squads/53… Ferland Men… https://fbref.com/en/players/3c…
## # … with 656 more rows
# Unimos la informacion
teams_players <- 
  all_players %>% 
  left_join(all_teams, by = "url_team")

teams_players
## # A tibble: 666 x 4
##    url_team                     player      url_player                   team   
##    <chr>                        <chr>       <chr>                        <chr>  
##  1 https://fbref.com/en/squads… Karim Benz… https://fbref.com/en/player… Real M…
##  2 https://fbref.com/en/squads… Casemiro    https://fbref.com/en/player… Real M…
##  3 https://fbref.com/en/squads… Sergio Ram… https://fbref.com/en/player… Real M…
##  4 https://fbref.com/en/squads… Thibaut Co… https://fbref.com/en/player… Real M…
##  5 https://fbref.com/en/squads… Raphaël Va… https://fbref.com/en/player… Real M…
##  6 https://fbref.com/en/squads… Dani Carva… https://fbref.com/en/player… Real M…
##  7 https://fbref.com/en/squads… Toni Kroos  https://fbref.com/en/player… Real M…
##  8 https://fbref.com/en/squads… Luka Modrić https://fbref.com/en/player… Real M…
##  9 https://fbref.com/en/squads… Federico V… https://fbref.com/en/player… Real M…
## 10 https://fbref.com/en/squads… Ferland Me… https://fbref.com/en/player… Real M…
## # … with 656 more rows

Ahora, la parte más interesante. Si entramos en la URL de un jugador en concreto, vemos que FBREF presenta distintas estadísticas que agrupa en distintas tablas (Standard Stats, Shooting, Passing, etc). Un planteamiento para extraer toda la información de las distintas tablas podría ser el siguiente. Creamos primero una función genérica que llamaremos extract_table, cuya labor es la de extraer la información de las tablas.

extract_table <- function(url,xpath=NULL) {
  
  session <- bow(url,
                 user_agent = 'Scrapping FBREF tutorial')
  
  # extract raw table
  raw_table <- 
    scrape(session) %>% 
    html_nodes(xpath = xpath) %>% 
    rvest::html_table() %>% 
    pluck(1)
  
  # set unique names
  names(raw_table) <- make.names(raw_table[1,], unique = TRUE)
  
  raw_table[-1,]
}

Ahora con la siguiente función tomamos todos los nodos donde podemos localizar las tablas y extraemos su información con la función previa. Utilizando de nuevo purrr::map y una misma función podemos extraer las estadísticas de todas las tablas. A partir de aquí, filtramos para obtener la información de la última temporada de Primera División.

exclude_vars <- c('Season' ,'Age', 'Squad', 'Country', 'Comp', 'LgRank', 'Matches', 'Min')

extract_all_stats <- function(url) {

  tables_xpath <- c('//*[@id="stats_standard_dom_lg"]',
                    '//*[@id="stats_shooting_dom_lg"]',
                    '//*[@id="stats_passing_dom_lg"]',
                    '//*[@id="stats_passing_types_dom_lg"]',
                    '//*[@id="stats_gca_dom_lg"]',
                    '//*[@id="stats_defense_dom_lg"]',
                    '//*[@id="stats_possession_dom_lg"]',
                    '//*[@id="stats_misc_dom_lg"]')
  
  all_stats <- map(.x = tables_xpath, .f = ~extract_table(url, xpath = .))
  
  joined_stats <- 
    bind_cols(map(.x = all_stats, 
                                .f = function(x) {
                                  x %>% 
                                    filter(Season == '2019-2020', Comp == '1. La Liga') %>% 
                                    select_if(!names(.) %in% exclude_vars) %>% 
                                    mutate(across(.cols = everything(),
                                                  .fns = as.numeric))
                                }), .name_repair = 'minimal')
  
  
  
  out <- 
    bind_cols(all_stats[[1]] %>% 
                     filter(Season == '2019-2020', Comp == '1. La Liga') %>% 
                     select(all_of(exclude_vars)), joined_stats, .name_repair = 'minimal') %>% 
    clean_names()
  
  out  
}

Finalmente, antes de iterar sobre todas las URL de los jugadores y extraer sus estadísticas, creamos una segunda función con purrr::safely que lo que permite es recoger, en cada iteración, una lista con dos slots: el resultado de la misma y el error (si lo hubiera). Esto hace que el proceso iterativo no se pare incluso aunque para una URL concreta no encontrara una de las tablas. En concreto, esto ocurre para algunos jugadores con muy poca participación, como canteranos de los equipos.

tst <- readRDS('~/r_devs/personal_site/content/temp/stats_players.rds')
safe_extract_all_stats <- safely(extract_all_stats)
tst <- purrr::map(.x = teams_players$url_player, ~ safe_extract_all_stats(.))

Finalmente, nos quedamos con aquellos jugadores de los que pudimos extraer todas sus estadísticas. Ahora solo faltaría limpiar un poco el data.frame y ya estaría!

tst %>% 
  map('result') %>% 
  compact() %>%
  bind_rows() %>% 
  .[,1:10] %>% 
  head()
##      season age       squad country       comp lg_rank matches   min mp starts
## 1 2019-2020  31 Real Madrid  es ESP 1. La Liga     1st Matches 3,141 37     36
## 2 2019-2020  27 Real Madrid  es ESP 1. La Liga     1st Matches 3,088 35     35
## 3 2019-2020  33 Real Madrid  es ESP 1. La Liga     1st Matches 3,013 35     35
## 4 2019-2020  27 Real Madrid  es ESP 1. La Liga     1st Matches 3,060 34     34
## 5 2019-2020  26 Real Madrid  es ESP 1. La Liga     1st Matches 2,822 32     32
## 6 2019-2020  27 Real Madrid  es ESP 1. La Liga     1st Matches 2,738 31     31

__

If you find this information useful, you might consider…

Buy Me A Coffee