Neste texto iremos trabalhar com o processo de Webscrapping de estatísticas de futebol dos jogos da Premier League, a mais alta liga de futebol inglesa. Apesar dos dados serem da temporada 22/23, o processo em tese é o mesmo para as outras temporadas. Aqui descrevemos as escolhas feitas, os empecilhos enfrentados e as soluções propostas. Os pacotes usados foram os seguintes:
library(httr2)
library(jsonlite)
library(lubridate)
library(tidyverse)
Muitos são os sites que armazenam e disponibilizam dados esportivos, alguns mais ou menos interativos, mais ou menos completos Optamos pelo próprio site da Premier League já que ele é a fonte oficial das informações, bem como usa uma apresentação interativa das estatísticas, dificultam a coleta usando do tradicional pacote rvest.
Cada temporada consiste em 38 rodadas com 10 jogos cada entre os 20 times da liga. Cada uma das partidas tem um identificador comum e estes seguem uma ordem padrão jogo após jogo. Por exemplo, a primeira partida foi entre Crsytal Palace e Arsenal, no dia 5 de Agosto de 2022, e possui o id 74911. A última partida foi entre Southampton e Liverpool, no dia 28 de Maio de 2023, e possui o id 75290. É possível acessar qualquer partida da temporada introduzindo um número entre 74911 e 75290 após a url https://www.premierleague.com/match/. Abaixo vemos isso com a partida de id 75001, entre Arsenal e Liverpool.
Em todas as páginas das partidas, temos um resumo da disputa e algumas abas interativas, isto é, que só são preenchidas por alguma informação quando o usuário interaje com elas. A aba de nosso interesse é a “Stats”, onde temos uma tabela com o nome de “Match Stats” dispondo das principais estatísticas da partida. São esses os dados que desejamos obter.
Em resumo, os dados coletados foram:
Informações das partidas, como data e times participantes;
Informações sobre a temporada, como id de partidas, número da rodada;
Informações sobre os times, como status (casa ou visitante), id de cada um deles e resultado final da partido;
Estatísticas das mais diversas sobre eventos da partida, como chutes a gol, escanteios, faltas, cartões, entre vários outros.
Para trabalhar com webscrapping em R temos três opções bastante conhecidas, cada uma crescendo em complexidade: rvest, httr/htrr2 e RSelenium.
rvest (Wickham, 2022) usa de elementos do código fonte de um site (sejam eles HTML ou CSS) para encontrar as informações desejadas que serão processadas como o usuário quiser. Apesar de ser muito prático, é uma alternativa que falha em sites com conteúdos gerados dinamicamente, seja por APIs ou JavaScript. Esse é o caso do site em questão aqui: as estatísticas são coletadas de outra fonte e apresentadas na tela apenas quando o usuário clica na aba de “Match Stats”. Elas não se encontram previamente no código fonte da página.
RSelenium (Harrison, 2023), por outro lado, simula um usuário interagindo com a página numa janela de navegador controlada no R. Usando de identificadores de HTML e CSS, é possível indicar ao pacote onde clicar e como, que informações extrair, que opções selecionar, etc. Há alguns problemas, porém: ele faz um uso muito mais intensivo da máquina do usuário; é mais suscetível a ser “pego” por mecanismos anti-webscrapping; apresenta algumas dificuldades ao interagir com loops; e constantemente precisa ser manipulado para evitar que as etapas de navegação sejam mais rápidas do que o próprio processamento do site.
A solução escolhida foi usar o pacote htrr2 (Wickham, 2023) uma atualização de outro pacote (htrr) visando otimizar processos. Em suma, esse pacote simula a interação de um navegador (client) a um servidor (server), gerando como resposta o resultado de um pedido (request).
O resultado da coleta, a response, é um objeto tipo list em R com múltiplos níveis. Um deles, o conteúdo da response, está escrito em código hexadecimal que, quando decodificado gera um arquivo em json contendo todos os nossos dados de interesse. Para efetuar todas essas transformações usamos o pacote jsonlite (Ooms, 2014) e a função fromJSON. Ao fim, obtemos um objeto também de tipo list, mas agora tratável e utilizável para análises.
O processo de request, tratamento das responses e limpeza dos dados consiste numa repetição ao longo das várias partidas de determinadas tarefas. Por isso foram desenvolvidas algumas funções auxiliares que são aplicadas nos links das requests de cada uma das partidas. Usamos das funções map e associadas do pacote purrr para isso.
As duas primeiras delas foram a colect_api e a colect_info, que farão a request na fonte original dos dados que o site da Premier League usa. Uma rápida checagem na aba “Rede” do painel DevTools do Firefox mostra que após atualizar a página de qualquer partida, o site original faz uma request a uma API no endereço https://footballapi.pulselive.com/football/stats/match/, completando após a barra com o id de cada partida. Além disso, checando no mesmo painel os headers da request, vemos que o site só permitirá acesso a resposta caso a origem (acess-control-allow-origin) do request seja o próprio site da Premier League.
A primeira delas monta uma request com a origem permitida pela API e executa o processo de coleta. A segunda delas seleciona a parte de interesse da response (nesse caso, o body), faz a conversão do formato hexadecimal para texto e em seguida interpreta o resultado como um arquivo json
colect_api <- function(link_root){
request(link_root) %>%
req_headers(Origin="https://www.premierleague.com") %>%
req_perform()
}
colect_info <- function(dados_api){
dados_api$body %>%
rawToChar() %>%
fromJSON()
}
Feito isso, já é possível obtermos um objeto que contém os dados de nosso interesse, restando apenas a aplicação para cada um dos jogos da temporada. Para gerar tais links, apenas uniremos o endereço original da API com uma sequência de ids partindo do primeiro (74911) até o último (75290).
match_link_root <- paste0("https://footballapi.pulselive.com/football/stats/match/",seq(74911,75290))
Passamos então esses links para nossa função colect_api e em seguida para a função colect_info. Ao invés de dependermos de loops, é possível usar a função map (e associadas) do pacote purrr. Elas servem para aplicar, ao longo de um objeto, uma função específica para cada um dos índices do elemento. Neste caso, coletamos a response de cada uma das requests e tratamos selecionamos apenas o nível body. O resultado final é uma lista onde cada índice é uma response. Além disso, renomeamos os elementos gerados pela função colect_info para trabalharmos com nomes mais fáceis de referenciar (ao invés de apenas números)
APIreq <- map(match_link_root,colect_api)
partidas <- map(APIreq,colect_info)
names(APIreq) <- paste0("id",seq(74911,75290))
Caso observemos rapidamente o conteúdo da response, veremos que cada partida possui dois elmentos: entity e data. O primeiro deles contém informações sobre a partida, como times, data, resultado, rodada, etc. O segundo deles contém o grosso das estatísticas esportivas, como quantidade de chutes, escanteios, faltas, etc. Abaixo constatamos isso para a partida de id 74911. Abaixo temos parte de seus conteúdos.
str(partidas$id74911$entity)
## List of 17
## $ gameweek :List of 3
## ..$ id : int 7831
## ..$ compSeason:List of 3
## .. ..$ label : chr "2022/23"
## .. ..$ competition:List of 5
## .. .. ..$ abbreviation: chr "EN_PR"
## .. .. ..$ description : chr "Premier League"
## .. .. ..$ level : chr "SEN"
## .. .. ..$ source : chr ""
## .. .. ..$ id : int 1
## .. ..$ id : int 489
## ..$ gameweek : int 1
## $ kickoff :List of 4
## ..$ completeness: int 3
## ..$ millis : num 1.66e+12
## ..$ label : chr "Fri 5 Aug 2022, 20:00 BST"
## ..$ gmtOffset : num 1
## $ provisionalKickoff:List of 4
## ..$ completeness: int 3
## ..$ millis : num 1.66e+12
## ..$ label : chr "Fri 5 Aug 2022, 20:00 BST"
## ..$ gmtOffset : num 1
## $ teams :'data.frame': 2 obs. of 2 variables:
## ..$ team :'data.frame': 2 obs. of 5 variables:
## .. ..$ name : chr [1:2] "Crystal Palace" "Arsenal"
## .. ..$ club :'data.frame': 2 obs. of 4 variables:
## .. .. ..$ name : chr [1:2] "Crystal Palace" "Arsenal"
## .. .. ..$ shortName: chr [1:2] "Crystal Palace" "Arsenal"
## .. .. ..$ abbr : chr [1:2] "CRY" "ARS"
## .. .. ..$ id : int [1:2] 6 1
## .. ..$ teamType : chr [1:2] "FIRST" "FIRST"
## .. ..$ shortName: chr [1:2] "Crystal Palace" "Arsenal"
## .. ..$ id : int [1:2] 6 1
## ..$ score: int [1:2] 0 2
## $ replay : logi FALSE
## $ ground :List of 4
## ..$ name : chr "Selhurst Park"
## ..$ city : chr "London"
## ..$ source: chr "OPTA"
## ..$ id : int 45
## $ neutralGround : logi FALSE
## $ status : chr "C"
## $ phase : chr "F"
## $ outcome : chr "A"
## $ attendance : int 25286
## $ clock :List of 2
## ..$ secs : int 5640
## ..$ label: chr "90 +4'00"
## $ fixtureType : chr "REGULAR"
## $ extraTime : logi FALSE
## $ shootout : logi FALSE
## $ behindClosedDoors : logi FALSE
## $ id : int 74911
str(partidas$id74911$data)
## List of 2
## $ 1:List of 1
## ..$ M:'data.frame': 149 obs. of 4 variables:
## .. ..$ name : chr [1:149] "formation_used" "total_final_third_passes" "final_third_entries" "fk_foul_won" ...
## .. ..$ value : num [1:149] 433 133 53 16 197 129 10 19 53 49 ...
## .. ..$ description : chr [1:149] "Todo: formation_used" "Todo: total_final_third_passes" "Todo: final_third_entries" "Todo: fk_foul_won" ...
## .. ..$ additionalInfo:'data.frame': 149 obs. of 0 variables
## $ 6:List of 1
## ..$ M:'data.frame': 135 obs. of 4 variables:
## .. ..$ name : chr [1:135] "formation_used" "total_back_zone_pass" "leftside_pass" "touches" ...
## .. ..$ value : num [1:135] 4231 308 155 770 562 ...
## .. ..$ description : chr [1:135] "Todo: formation_used" "Todo: total_back_zone_pass" "Todo: leftside_pass" "Todo: touches" ...
## .. ..$ additionalInfo:'data.frame': 135 obs. of 0 variables
O principal problema do formato dos dados é a existência de listas dentro de listas com tamanhos diferentes, bem como informações relevantes espalhadas dentro de conteúdo irrelevante. Os próximos passos fazem justamente esse tratamento: primeiro limpando entity e segundo data. Para isso, nos utilizamos de mais três funções auxiliares: clean_match_info, clean_match_stats e pluck_multiple.
pluck_multiple <- function(obj_list,to_keep){
obj_list %>%
keep(names(.) %in% to_keep)}
clean_match_info <- function(list_info){
info_id_pk <- paste0(list_info[["entity"]][["teams"]][["team"]][["id"]],
"_",
list_info[["entity"]][["id"]])
info_df <- data.frame(list_info[1]) %>%
unnest_wider(col=entity.teams.team,
names_repair = "minimal",
names_sep = "_")
info_df_comp <- info_df %>%
mutate(entity.kickoff.label=dmy_hm(entity.kickoff.label)) %>%
select(entity.gameweek.gameweek,entity.kickoff.label,entity.teams.team_name,
entity.teams.team_id,entity.teams.score,entity.id) %>%
cbind("Team_status"=c("Home","Away"),
info_id_pk)
names(info_df_comp) <- c("Gameweek","Date_of_match","Team_name",
"Team_id","Team_score","Match_id",
"Team_status","pk")
return(info_df_comp)}
clean_match_stats <- function(list_stats){
stats_id_pk <- paste0(names(list_stats[[2]]),
"_",
list_stats[["entity"]][["id"]])
stats_df <- list_flatten(list_stats[[2]]) %>%
map(pluck_multiple,c("name","value"))
stats_df_f <- stats_df %>%
map(~pivot_wider(.x,names_from="name")) %>%
bind_rows() %>%
cbind(pk=stats_id_pk)
return(stats_df_f)}
Um breve comentário sobre o tamanho das duas funções clean. Dado que algumas das funções não lidam bem com o operador pipe (onde passamos o resultado de um processo como primeiro argumento do segundo processo), criamos dentro de cada uma delas esses objetos temporários para facilitar as etapas de tratamento. De fato, há uma perda de inteligibilidade do código, o que poderia dificultar um eventual processo de debug futuro. Em especial, podemos ainda perder escabilidade das funções, que podem deixar de funcionar já na próxima temporada da Premier League.
Tendo construído nossas funções, aplicamos elas no nosso objeto da response usando um map_dfr, que aplica as funções desejadas ao longo de um objeto e gera como resultado um data.frame. Usamos a função as_tibble junto com um print para ver algumas das observações de cada objeto.
match_info <- map_dfr(partidas,clean_match_info)
match_stats <- map_dfr(partidas,clean_match_stats)
as_tibble(match_info) %>%
print(n=5)
## # A tibble: 760 × 8
## Gameweek Date_of_match Team_name Team_id Team_score Match_id Team_status
## <int> <dttm> <chr> <int> <int> <int> <chr>
## 1 1 2022-08-05 20:00:00 Crystal … 6 0 74911 Home
## 2 1 2022-08-05 20:00:00 Arsenal 1 2 74911 Away
## 3 1 2022-08-06 15:00:00 Bournemo… 127 2 74912 Home
## 4 1 2022-08-06 15:00:00 Aston Vi… 2 0 74912 Away
## 5 1 2022-08-06 17:30:00 Everton 7 0 74913 Home
## # ℹ 755 more rows
## # ℹ 1 more variable: pk <chr>
as_tibble(match_stats) %>%
print(n=5)
## # A tibble: 760 × 240
## formation_used total_final_third_passes final_third_entries fk_foul_won
## <dbl> <dbl> <dbl> <dbl>
## 1 433 133 53 16
## 2 4231 142 65 9
## 3 433 200 82 18
## 4 3421 77 46 16
## 5 343 199 61 14
## # ℹ 755 more rows
## # ℹ 236 more variables: accurate_back_zone_pass <dbl>, poss_lost_all <dbl>,
## # aerial_lost <dbl>, accurate_chipped_pass <dbl>, duel_lost <dbl>,
## # total_long_balls <dbl>, total_chipped_pass <dbl>,
## # accurate_fwd_zone_pass <dbl>, blocked_pass <dbl>, passes_left <dbl>,
## # fouled_final_third <dbl>, ball_recovery <dbl>, total_throws <dbl>,
## # poss_lost_ctrl <dbl>, fwd_pass <dbl>, backward_pass <dbl>, …
Finalmente, unimos os dois objetos com um inner_join usando nossa primary key.
premierdb_22_23 <- inner_join(match_info,match_stats,
by="pk")
as_tibble(premierdb_22_23) %>%
print(n=10)
## # A tibble: 760 × 247
## Gameweek Date_of_match Team_name Team_id Team_score Match_id
## <int> <dttm> <chr> <int> <int> <int>
## 1 1 2022-08-05 20:00:00 Crystal Palace 6 0 74911
## 2 1 2022-08-05 20:00:00 Arsenal 1 2 74911
## 3 1 2022-08-06 15:00:00 Bournemouth 127 2 74912
## 4 1 2022-08-06 15:00:00 Aston Villa 2 0 74912
## 5 1 2022-08-06 17:30:00 Everton 7 0 74913
## 6 1 2022-08-06 17:30:00 Chelsea 4 1 74913
## 7 1 2022-08-06 12:30:00 Fulham 34 2 74914
## 8 1 2022-08-06 12:30:00 Liverpool 10 2 74914
## 9 1 2022-08-06 15:00:00 Leeds United 9 2 74915
## 10 1 2022-08-06 15:00:00 Wolverhampton Wande… 38 1 74915
## # ℹ 750 more rows
## # ℹ 241 more variables: Team_status <chr>, pk <chr>, formation_used <dbl>,
## # total_final_third_passes <dbl>, final_third_entries <dbl>,
## # fk_foul_won <dbl>, accurate_back_zone_pass <dbl>, poss_lost_all <dbl>,
## # aerial_lost <dbl>, accurate_chipped_pass <dbl>, duel_lost <dbl>,
## # total_long_balls <dbl>, total_chipped_pass <dbl>,
## # accurate_fwd_zone_pass <dbl>, blocked_pass <dbl>, passes_left <dbl>, …
Uma última etapa é executada para finalizar nossa base: lidar com NAs. Eles ocorrem porque a função bind_rows preserva as colunas das duas bases unidades e preenche as observações que não apresentam algum dos valores com NAs. No nosso caso, trata-se de estatísticas que alguns dos times não produziram durante uma determinada partida.
premierdb_22_23[is.na(premierdb_22_23)] <- 0
Agora que temos nossos dados, podemos rodar análises das mais diversas com base nos nossos interesses. Agora notável de menção é a profusão de estatísticas obtidas dessa forma em comparação com aquelas disponíveis no site oficial da Premier League. Elas nos permitem maior especificidade naquilo que desejamos plotar, mas também nos sobrecarregam quanto as decisões a serem feitas.
Abaixo temos um dos possíveis gráficos a serem gerados: um boxplot do números de chutes ao gol por jogo dos líderes da temporada Arsenal e Manchester City ao longo das 38 rodadas.
premierdb_22_23 %>%
filter(Team_name %in% c("Manchester City","Arsenal")) %>%
select(Gameweek,Team_name,total_scoring_att) %>%
ggplot()+
geom_boxplot(aes(x=Team_name,y=total_scoring_att))
Pelos resultados vemos que ambos os times foram bastante consistentes com suas tentativas de pontuar, mantendo aproximadamente 50% dos jogos na faixa entre 10 e 20 tentativas. As sombras indicam alguma flexibilidade mas sempre tives casos acima dos 5 chutes para ambos (sendo esse valor o mínimo para Manchester City, dado o ponto preto outlier). Também podemos distinguir jogos onde o time jogou como casa ou visitante.
premierdb_22_23 %>%
filter(Team_name %in% c("Manchester City","Arsenal")) %>%
select(Gameweek,Team_name,Team_status,total_scoring_att) %>%
ggplot()+
geom_boxplot(aes(x=Team_name,y=total_scoring_att,fill=Team_status))
Há uma tradicional crença de que jogos em casa são mais fáceis que jogos como visitante e esses gráficos parecem confirmar isso. Porém, vemos que a distinção é muito maior para o Arsenal do que para o Manchester City, este último sendo mais consistente com suas estratégias. Talvez esse seja uma das razões pelas quais o primeiro time ficou no topo da tabela por mais de dois terços da duração da temporada e acabou desbancado nas últimas rodadas pelo Manchester City. Um time foi mais consistente que outro.
Há várias outras estatísticas na base. Posteriores análises poderão selecionar variáveis de interesse e remover dados desnecessários (como indicadores que pouco nos informam). Além disso, podemos buscar métodos confirmatórios e preditivos mais robustos a fim de gerar insights relevantes sobre o desempenho dos clubes ao longo da temporada, como análise de clusters para estratégias comuns ou regressões lineares e/ou logísticas.