Interagindo com a API XML do BoardGameGeek

Neste post eu mostro como obter e fazer os parser dos dados dos jogos de tabuleiro do BoardGameGeek, obtidos através de sua API XML. O processo apresentado aqui faz uso e complementa o que já foi apresentado no scrapper do ranking do BGG, e servirá de base para alguns posts que penso em escrever no futuro.

true
01-23-2022

Motivação

Um dos primeiros posts do blog foi sobre um scrapper para obtermos as informações da página do ranking do BoardGameGeek, um portal especializado em jogos de tabuleiro. Meu principal objetivo com isso não foi o de extrair o ranking em si, mas o de extrair o identificador único de cada jogo para usá-lo junto à API XML oferecida pelo portal. Essa API nos dá acesso à (praticamente) todas as informações sobre os jogos que estão disponíveis em suas respectivas páginas e, portanto, fornece um caminho mais simples para obtermos os dados sobre cada um deles de forma programática. No entanto, o mais importante para mim é que essas informações têm o potencial de facilitar uma das coisas que eu mais tenho dificuldade: encontrar os jogos que combinam com aquilo que eu curto.

Neste post eu mostro como interagir com a API XML do BGG e, também, fazer o parser das informações que obtemos a partir dela. Esse processo vai ser importante para entendermos os tipos de dados que temos disponíveis e as possíveis formas de usá-los nos próximos posts.

Funcionamento da API

A primeira coisa que precisaremos fazer para interagir com a API do BGG é carregar a base que contém o identificador único de cada jogo. Estes dados podem ser obtidos a partir do scrapper disponível neste link, ou você pode simplesmente usar a base de dados disponível junto a esse post. A informação que precisaremos está na coluna id na tabela abaixo.

# carregando os pacotes
library(tidyverse) # core
library(httr) # para fazer o scraping
library(xml2) # para o parser
library(fs) # para manipular paths

## lendo os dados do ranking da BGG para pegar o identificador unico de cada jogo
ranking <- read_rds(file = 'data/ranking_bgg.rds') %>% 
  # dropando os jogos que não possuem game_id
  drop_na(id)

## visualizando a tabela
rmarkdown::paged_table(x = ranking)

A API do BGG funciona através de requisições do tipo GET, que retornam um arquivo xml (eXtensible Markup Language) cujos parâmetros e valores seguem o padrão de tags e atributos comum à páginas HTML. Não é necessário fazer qualquer tipo de autenticação para usar essa API, bastando fazer àquela requisição diretamente para o endpoint selecionado e empregando os métodos desejados. Falando nisso, não tenho certeza se essa API é RESTful ou não (REpresentational State Transfer), uma vez que (1) apenas requisições do tipo GET são suportadas, (2) as requisições para um determinado tipo de informação são todas feitas através de um único endpoint e (3) os nomes dos métodos utilizados devem ser passados diretamente para a url da requisição. De toda forma, a documentação da API pode ser encontrada aqui e, se você souber me dizer se ela é RESTful ou não, eu agradeceria.

Existem diversos tipos de informação disponíveis no site do BGG que podem ser obtidas através dessa API, tais como os dados dos jogos (i.e., thing), das coleções (i.e., collection) e dos próprios usuários (i.e., user) e do fórum (i.e., forum). Além disso, dentro de cada tipo de informação desta, podemos buscar detalhes específicos, tais como tudo o que há no marketplace para um jogo ou os resultados das partidas dos jogos registrados por cada usuário. Embora haja uma infinidade de informalões, neste post vamos focar apenas nas relativas às características dos jogos e, portanto, vamos usar o endpoint https://www.boardgamegeek.com/xmlapi2/thing.

Existem diversos métodos que podemos ser usados junto daquele endpoint. Entretanto, vamos usar aqui o id do jogo (que vamos extrair da tabela que carregamos acima), as estatísticas relacionadas à cada jogo (setando stats=1) e uma amostra de, no máximo, 100 comentários associados à cada jogo (setando ratingcomments=1 e pagesize=100). Existem outros métodos associados a esse endpoint, como os vídeos que falam sobre o jogo (i.e., videos=1) e as informações dos anúncios do marketplace (i.e., marketplace=1), mas não vamos trabalhar com eles aqui. De toda forma, precisamos juntar o endereço do endpoint e os métodos para compor a url para o GET: fazemos isso separando a url do endpoint das strings dos métodos utilizando o ? e cada método do outro usando o &. Com isso, a url ficará assim: https://www.boardgamegeek.com/xmlapi2/thing?id=<game_id>&stats=1&ratingcomments=1&pagesize=100 - onde o game_id vai ser um número correspondente ao identificador numérico do jogo.

A função abaixo cuidará de fazer essa requisição, salvando uma cópia do HTML da resposta em disco caso o valor passado para o argumento path_to_save não seja NULL.

## funcao para pegar o XML de um jogo
pega_jogo <- function(game_id, path_to_save = NULL) {
  # url base para pegar um jogo, as estatisticas e a primeira pagina de comentarios
  base_url <- str_glue('https://www.boardgamegeek.com/xmlapi2/thing?id={game_id}&stats=1&ratingcomments=1&pagesize=100')
  
  # fazendo o request e salvando o codigo da resposta se o path nao for nulo
  if(!is.null(path_to_save)){
    GET(url = base_url, 
        write_disk(path = sprintf(fmt = '%s/%08d.html', path_to_save, game_id), 
                   overwrite = TRUE)
    )
  } else {
    GET(url = base_url)
  }
  
}

Vamos usar a função para fazer a requisição de um jogo, salvando o xml resultante em disco. Para isso, vou criar um diretório temporário para armazenar o xml e, também, extrair o identificador numérico do jogo que utilizaremos nesse exemplo: o Ticket to Ride. Esse é um jogo de construir rotas com trenzinhos, e é muito legal e divertido.

## setando o path onde vamos jogar os arquivos
path_scrapped_data <- 'temporario'

## criando pasta se ela nao existir
if(!dir_exists(path_scrapped_data)){
  dir_create(path = path_scrapped_data, recurse = TRUE)
}

# pegando o id do jogo que vamos usar no exemplo
id_do_jogo <- ranking %>% 
  # pegando o jogo wingspan
  filter(titulo == 'Ticket to Ride') %>% 
  # pegando o id do jogo
  pull(id) %>% 
  # parseando o id para um numero
  parse_number()

# fazendo o request do XML do jogo
xml_do_jogo <- pega_jogo(game_id = id_do_jogo, path_to_save = path_scrapped_data)

Podemos pegar o content da resposta da requisição e usar a função xml_structure para entender a sua estrutura. Isso nos ajuda bastante a identificar as tags que precisamos buscar, bem como o tipo de resultado que deve estar associado à cada uma delas. Como o output dessa função é bastante longo, resolvi usar outra abordagem aqui no post só para dar uma ideia do que existe dentro da resposta: usei a função as_list do pacote xml2 para parsear o código para uma lista do R. O resultado disso, é uma lista aninhada, começando pelo elemento único items que, por sua vez, tem outro elemento único chamado item dentro dela e, finalmente, os elementos que estamos buscando. Para obter esses objetos e extrair o nome delas, usei a função pluck, seguida da função names para extrair o nome das tags e table para contar quantas vezes cada uma aparece. O resultado que obtemos com isso é apresentado abaixo, onde podemos ver quantas vezes cada tag aparece dentro do xml: algumas aparecem uma única vez, outras aparecem dezenas ou centenas de vezes (i.e., links, name e pool).

xml_do_jogo %>% 
  # pega o conteudo da response
  content() %>% 
  # coloca o conteudo como uma lista
  as_list() %>% 
  # pega os elementos da lista que estao dentro de item
  pluck('items', 'item') %>% 
  # olha os nomes dos elementos na sublista
  names %>% 
  # contando quantas vezes cada nome aparece
  table
.
     comments   description         image          link    maxplayers 
            1             1             1           200             1 
  maxplaytime        minage    minplayers   minplaytime          name 
            1             1             1             1            16 
  playingtime          poll    statistics     thumbnail yearpublished 
            1             3             1             1             1 

E com isso fechamos a parte da obtenção dos dados da API do BGG, que se mostrou bastante simples: sem autenticação, só usar o enpoint e os métodos que queremos. Por outro lado, veremos a seguir que fazer o parser dessas informações é bem mais trabalhoso.

Parseando o response

O conteúdo da requisição é bastante heterogêneo, e fazer o parser dele fica mais fácil de ser entendido se pensarmos em quatro tipos de informação: (1) àquelas de cunho geral e que são representadas por uma única quantidade (e.g., idade mínima, tempo de jogo,…) ou facilmente resumidas à uma única informação (e.g., nome do jogo), (2) os comentários associados à cada jogo, (3) as que trazem uma opinião específica da comunidade em torno do jogo e que são representadas por tabelas consolidando o resultado de votações (e.g., idade recomendada, dependência do idioma) e (4) os metadados que trazem detalhes específicos do jogo e que são apresentadas como tabelas contendo cada um destes metadados de forma discriminada (e.g., mecânicas, autores,…). Veremos como fazer o parser de cada informação desta e o que obtemos como resultado em cada caso.

Informações genéricas

A primeira informação geral que vamos parsear é àquela que está dentro da tag name. Para isso, precisaremos pegar tudo o que está associado à essa tag usando um xml_find_all e extrair os atributos de cada elemento com xml_attrs. O resultado dessa operação é uma lista onde cada elemento é um data.frame com uma única linha, contendo um nome do jogo e um indicador se esse nome é o oficial (i.e., primary) ou o não-oficial (i.e., alternate). Juntaremos essas linhas usando um bind_rows seguido de um select para organizar o resultado. Aplicando essa função ao conteúdo do xml, obtemos uma tabelinha com todos os nomes do jogo, onde podemos ver que o os nomes não-oficiais são normalmente aqueles em outras línguas. Essa tabela é bastante útil pois, com base nela, podemos fazer um de-para das informações do BGG para àquelas da Ludopedia, o que nos permite responder à algumas perguntas que havíamos aberto anteriormente. De toda forma, se quiséssemos extrair apenas o nome oficial do jogo, bastava usar um filter pelo valor primary, seguido de um pull da coluna título.

## funcao para parsear a lista de nomes do jogo
parser_nome <- function(arquivo_xml) {
  # pega o arquivo HTML
  arquivo_xml %>% 
    # extrai todas as tags name
    xml_find_all(xpath = '*//name') %>% 
    # extrai todo os atributos dessas tags
    xml_attrs() %>% 
    # junta todos os atributos em uma tibble
    bind_rows() %>% 
    # remove a coluna sortindex
    select(titulo = value, metadado = type)
}

# parseando os nomes
parser_nome(arquivo_xml = content(x = xml_do_jogo))
# A tibble: 16 × 2
   titulo                           metadado 
   <chr>                            <chr>    
 1 Ticket to Ride                   primary  
 2 Les Aventuriers du Rail          alternate
 3 Jízdenky, prosím!                alternate
 4 Menolippu                        alternate
 5 Ticket to Ride سكة سفر           alternate
 6 Wsiąść do Pociągu                alternate
 7 Zug um Zug                       alternate
 8 ¡Aventureros al Tren!            alternate
 9 Τρενάκια                         alternate
10 เกมต่อรถไฟ                        alternate
11 チケット・トゥ・ライド・アメリカ alternate
12 乗車券                           alternate
13 鐵道任務                         alternate
14 鐵道任務 Ticket to Ride          alternate
15 铁路环游                         alternate
16 티켓 투 라이드                   alternate

A próxima informação que vamos parsear é a descrição do jogo, que é um string contendo toda a estorinha e contexto sobre o jogo. Essa informação está dentro da tag description, e basta usarmos o xml_text para pegá-la.

## funcao para parsear a descricao do jogo
parser_descricao <- function(arquivo_xml) {
  # pega o arquivo HTML
  arquivo_xml %>% 
    # extrai todas as tags description
    xml_find_first(xpath = '*//description') %>% 
    # extrai o texto da descricao
    xml_text()
}

# parseando a descricao
parser_descricao(arquivo_xml = content(x = xml_do_jogo))
[1] "With elegantly simple gameplay, Ticket to Ride can be learned in under 15 minutes. Players collect cards of various types of train cars they then use to claim railway routes in North America. The longer the routes, the more points they earn. Additional points come to those who fulfill Destination Tickets &ndash; goal cards that connect distant cities; and to the player who builds the longest continuous route.&#10;&#10;&quot;The rules are simple enough to write on a train ticket &ndash; each turn you either draw more cards, claim a route, or get additional Destination Tickets,&quot; says Ticket to Ride author, Alan R. Moon. &quot;The tension comes from being forced to balance greed &ndash; adding more cards to your hand, and fear &ndash; losing a critical route to a competitor.&quot;&#10;&#10;Ticket to Ride continues in the tradition of Days of Wonder's big format board games featuring high-quality illustrations and components including: an oversize board map of North America, 225 custom-molded train cars, 144 illustrated cards, and wooden scoring markers.&#10;&#10;Since its introduction and numerous subsequent awards, Ticket to Ride has become the BoardGameGeek epitome of a &quot;gateway game&quot; -- simple enough to be taught in a few minutes, and with enough action and tension to keep new players involved and in the game for the duration.&#10;&#10;Part of the Ticket to Ride series.&#10;&#10;"

O próximo parser estrutura diversas informações relacionadas ao ano de publicação do jogo, quantidade de jogadores, tempo de jogo e idade mínima. Todas essas informações estão como tags soltas dentro do xml, portanto tive que usar o self dentro do xpath para pegar cada uma delas e colocar dentro do mesmo resultado. Entretanto, uma vez que conseguimos extrair essas informações, usamos um map_dfr para as colocarmos como um tibble e, depois, um pivot_wider para organizá-las entre colunas.

## funcao para parsear as informacoes do jogo
parser_informacoes <- function(arquivo_xml) {
  # pega o arquivo HTML
  arquivo_xml %>% 
    # extrai todas as tags relacionadas às informações sobre o ano de publicacao, quantidade de jogadores
    # idade minima para o jogo e tempo de jogo
    xml_find_all(xpath = '*//*[self::yearpublished or self::minplayers or self::maxplayers
               or self::playingtime or self::minplaytime or self::maxplaytime or self::minage]') %>% 
    # colocando todos os atributos dessa tag em um tibble
    map_dfr(
      ~ list(
        caracteristica = xml_name(.x),
        valor = xml_attrs(.x, 'value')
      )) %>% 
    # parseando tudo para numerico
    mutate(valor = parse_number(x = valor)) %>% 
    # passando o tibble do formato longo para o largo
    pivot_wider(names_from = caracteristica, values_from = valor)
}

# parseando as informacoes
parser_informacoes(arquivo_xml = content(x = xml_do_jogo))
# A tibble: 1 × 7
  yearpublished minplayers maxplayers playingtime minplaytime
          <dbl>      <dbl>      <dbl>       <dbl>       <dbl>
1          2004          2          5          60          30
# … with 2 more variables: maxplaytime <dbl>, minage <dbl>

Quando passamos o método stats=1 para o endpoint também coletamos as informações sobre as estatísticas relacionadas ao ranking do jogo que escolhemos. Com isso vamos ter acesso: (a) às informações relativas às notas e (b) àquelas diretamente relacionadas aos rankings em que cada jogo aparece. A função abaixo dá conta de parsear o primeiro destes, nos dando acesso à quantidade de votos que cada jogo recebeu, a nota média (arimética e bayesiana) e outras informações relacionadas ao interesse dos usuários pelo jogo.

## funcao para parsear todas as informacoes relacionadas à avaliação de cada jogo
parser_avaliacoes <- function(arquivo_xml) {
  # extrai outras informacoes das avaliacoes e junta com as informacoes de rankings e contagem de comentarios
  arquivo_xml %>% 
    # extrai todas as tags relacionadas dentro das avaliacoes que nao estejam relacionadas ao rankeamento
    xml_find_all(xpath = '*/statistics/ratings/*[not(self::ranks)]') %>% 
    # coloca todas as informacoes dentro de um tibble
    map_dfr(
      ~ list(
        estatistica = xml_name(.x),
        valor = xml_attrs(.x, 'value')
      )
    ) %>% 
    # parseando tudo para numerico
    mutate(valor = parse_number(x = valor)) %>% 
    # passando o tibble do formato longo para o largo
    pivot_wider(names_from = estatistica, values_from = valor)
}

# parseando as avaliacoes
parser_avaliacoes(arquivo_xml = content(x = xml_do_jogo))
# A tibble: 1 × 12
  usersrated average bayesaverage stddev median  owned trading wanting
       <dbl>   <dbl>        <dbl>  <dbl>  <dbl>  <dbl>   <dbl>   <dbl>
1      76598    7.41         7.30   1.30      0 106496     941     693
# … with 4 more variables: wishing <dbl>, numcomments <dbl>,
#   numweights <dbl>, averageweight <dbl>

Já a função abaixo parseia os rankings em que cada jogo aparece. Além do ranking geral, cada jogo também pode estar posicionado dentro da família de jogos à que pertence e/ou aos tipos de mecânica associados a ele. No nosso exemplo, podemos ver que o Ticket to Ride ocupa a 193º posição do ranking geral e a 40º posição quando o assunto são os jogos familiares.

## funcao para parsear todas as informacoes relacionadas aos rankings em que cada jogo esta
parser_rankings <- function(arquivo_xml) {
  # pega o arquivo html
  arquivo_xml %>% 
    # extrai todas as tags que estejam relacionadas ao ranking
    xml_find_all(xpath = '*/statistics/ratings/ranks/rank') %>% 
    # extrai todo os atributos dessas tags
    xml_attrs('value') %>% 
    # junta todos os atributos em uma tibble
    bind_rows() %>% 
    # renomeando colunas
    rename(nivel = type, tipo = name, nome = friendlyname, 
           posicao = value, media_bayesiana = bayesaverage) %>% 
    # parseando os numericos para tal
    mutate(
      posicao         = parse_number(x = posicao),
      media_bayesiana = parse_number(x = media_bayesiana)
    )
}

# parseando os rankings
parser_rankings(arquivo_xml = content(x = xml_do_jogo))
# A tibble: 2 × 6
  nivel   id    tipo        nome             posicao media_bayesiana
  <chr>   <chr> <chr>       <chr>              <dbl>           <dbl>
1 subtype 1     boardgame   Board Game Rank      193            7.30
2 family  5499  familygames Family Game Rank      41            7.30

Comentários

O método ratingcomments=1 faz com que tenhamos acesso aos comentários e notas individuais associadas à cada jogo. Essas informações estão dentro da tag comment, aninhada em comments, e podemos usar a função abaixo para extrair os 100 comentários que virão junto do xml. O resultado dela é um tibble onde temos uma coluna com o identificador único, a nota dada ao jogo e o comentário feito por um dado usuário. Esse identificador do usuário pode até ser usado em outros endpoints disponíveis na API, como aquele que nos dá acesso às coleções.

## funcao para parsear os comentarios sobre o jogo
parser_comentarios <- function(arquivo_xml) {
  # pega o arquivo HTML
  arquivo_xml %>% 
    # extrai todas as tags de comentario
    xml_find_all(xpath = '*/comments/comment') %>% 
    # extrai todo os atributos dessas tags
    xml_attrs() %>% 
    # junta todos os atributos em uma tibble
    bind_rows() %>% 
    # renomeando as colunas
    rename(usuario = username, nota = rating, comentario = value) %>% 
    # parseando as notas para numerico
    mutate(nota = parse_number(x = nota))
}

# parseando os comentarios
parser_comentarios(arquivo_xml = content(x = xml_do_jogo))
# A tibble: 100 × 3
   usuario          nota comentario                                   
   <chr>           <dbl> <chr>                                        
 1 Steffen S.         10 ""                                           
 2 harry the horse    10 ""                                           
 3 zapator            10 "Very accessible for all kind of gamers. A f…
 4 madouc             10 "TThis is the game of the year !   5 good re…
 5 southj95           10 "Excellent Game! Very easy to teach (took li…
 6 Jabberwock         10 ""                                           
 7 Skyjack            10 ""                                           
 8 Sunfox             10 "Just as good as the comments say it is! Eve…
 9 SteffenS           10 ""                                           
10 Babluit            10 ""                                           
# … with 90 more rows

Um ponto importante é que estamos limitados à obter um máximo de 100 comentários por requisição à API (usamos o método pagesize=100 para garantir que isso vá acontecer). Desta forma, se quisermos pegar os comentários de 101 à 200 (e assim sucessivamente), devemos correr entre as ‘páginas’ dos comentários usando o método page=numero (onde número é o número da página). No entanto, se fossemos fazer isso precisávamos ter noção de quantos comentários existem para poder chegar ao número máximo de páginas que podermos passar para o método. É possível extrairmos estas informações olhando diretamente a tag comments, e extraindo o atributo totalitems. Assim, para obtermos todos os comentários para esse jogo, bastaria dividirmos essa quantidade por 100, e iterar da página 1 até este máximo usando o método page junto do endpoint.

## funcao para parsear a quantidade total de comentarios que um jogo tem
parser_comentarios_total <- function(arquivo_xml) {
  # pega o arquivo html
  arquivo_xml %>% 
    # pega tudo o que está sobre a tag comments
    xml_find_all(xpath = '*/comments') %>% 
    # pega apenas o valor correspondente ao total de comentarios
    xml_attr('totalitems') %>% 
    # parseando o string para numero
    parse_number()
}

# parseando a descricao
parser_comentarios_total(arquivo_xml = content(x = xml_do_jogo))
[1] 76974

Resultados das votações

Existem três tags com o nome pool dentro do xml, e as funções a seguir tratam de fazer o parser deste conteúdo. Essas tags nada mais são do que os resultados de votações abertas sobre o número de jogadores, idade sugerida e dependência do idioma, nas quais os usuários do portal do BGG puderam opinar em torno desses três grupos de informação.

O parser abaixo pega os resultados da votação relacionada ao número de jogadores, retornando as informações sobre a melhor e a pior quantidade de jogadores, além da quantidade recomendada per se. Se quiséssemos colocar essas informações em suas próprias colunas, faria bastante sentido agrupar a tabela abaixo pela coluna voto e pegar o num_jogadores que tivesse a maior quantidade de votos usando um slice_max com n = 1, passando o resultado disso para um pivot_wider depois. Não mostro como fazer isso aqui, mas seria uma opção viável para resumirmos essas informações em torno dessas três recomendações (i.e. a pior quantidade de jogadores, a melhor quantidade de jogadores, e a quantidade recomendada de jogadores).

## funcao para parsear os resultados da votacao do melhor numero de jogadores para se jogar
parser_votacao_n_jogadores <- function(arquivo_xml) {
  # pega o arquivo html
  arquivo_xml %>% 
    # extrai todas as tags com os resultados da votação relacionada ao melhor numero de jogadores
    xml_find_all(xpath = '*/poll[@name="suggested_numplayers"]/results') %>% 
    # coloca tudo dentro de um tibble
    map_dfr(
      ~ xml_find_all(.x, xpath = 'result') %>% 
        xml_attrs() %>% 
        bind_rows() %>% 
        mutate(numplayers = xml_attrs(.x))
    )  %>% 
    # renomeia e organiza as colunas
    select(num_jogadores = numplayers, voto = value, num_votos = numvotes) %>% 
    # parseando votos para numerico
    mutate(num_votos = parse_number(x = num_votos))
}

# parseando o resultado da votacao da melhor quantidade de jogadores
parser_votacao_n_jogadores(arquivo_xml = content(x = xml_do_jogo))
# A tibble: 18 × 3
   num_jogadores voto            num_votos
   <chr>         <chr>               <dbl>
 1 1             Best                    1
 2 1             Recommended             7
 3 1             Not Recommended       620
 4 2             Best                   73
 5 2             Recommended           504
 6 2             Not Recommended       224
 7 3             Best                  244
 8 3             Recommended           545
 9 3             Not Recommended        41
10 4             Best                  634
11 4             Recommended           231
12 4             Not Recommended        13
13 5             Best                  293
14 5             Recommended           434
15 5             Not Recommended        54
16 5+            Best                    6
17 5+            Recommended            20
18 5+            Not Recommended       457

O parser seguinte olha o resultado da votação sobre a idade mínima sugerida para o jogo. Novamente, se quiséssemos resumir as informações dessa tabela à uma única linha (i.e., qual a idade recomendada pela comunidade para o jogo), bastaria que usássemos um filter para reter a linha que tivesse a maior quantidade de votos (i.e., num_votos == max(num_votos)) e, então, usar um pull na coluna idade_ideal.

## funcao para parsear os resultados da votacao da idade recomendada para o jogo
parser_votacao_idade <- function(arquivo_xml) {
  # pega o arquivo html
  arquivo_xml %>% 
    # extrai todas as tags com os resultados da votação relacionada à idade recomendada para o jogo
    xml_find_all(xpath = '*/poll[@name="suggested_playerage"]/results') %>% 
    # coloca tudo dentro de um tibble
    map_dfr(
      ~ xml_find_all(.x, xpath = 'result') %>% 
        xml_attrs()
    ) %>% 
    # renomeia e organiza as colunas
    select(idade_ideal = value, num_votos = numvotes) %>% 
    # parseando votos para numerico
    mutate(num_votos = parse_number(x = num_votos))
}

# parseando o resultado da votacao da melhor idade para o jogo
parser_votacao_idade(arquivo_xml = content(x = xml_do_jogo))
# A tibble: 12 × 2
   idade_ideal num_votos
   <chr>           <dbl>
 1 2                   0
 2 3                   0
 3 4                   3
 4 5                   5
 5 6                  67
 6 8                 173
 7 10                 40
 8 12                  4
 9 14                  2
10 16                  0
11 18                  1
12 21 and up           0

Finalmente, o último parser relacionado às votações fala da dependência do idioma para o jogo. Essa informação indica o quanto dependemos de entender o que está escrito no livro de regras, cartas e etc de forma a conseguir jogar. O resultado desse parser é mais uma vez uma tabelinha e, para extrair a recomendação da comunidade, bastaria que repetimos o processo descrito acima, mas usando um pull na coluna voto.

## funcao para parsear os resultados da votacao sobre a dependencia do idioma para jogar o jogo
parser_votacao_idioma <- function(arquivo_xml) {
  # pega o arquivo html
  arquivo_xml %>% 
    # extrai todas as tags com os resultados da votacao sobre a dependencia do idioma para jogar o jogo
    xml_find_all(xpath = '*/poll[@name="language_dependence"]/results') %>% 
    # coloca tudo dentro de um tibble
    map_dfr(
      ~ xml_find_all(.x, xpath = 'result') %>% 
        xml_attrs()
    ) %>% 
    # renomeia e organiza as colunas
    select(voto = value, num_votos = numvotes) %>% 
    # parseando votos para numerico
    mutate(num_votos = parse_number(x = num_votos))
}

# parseando o resultado da votacao da dependencia do idioma
parser_votacao_idioma(arquivo_xml = content(x = xml_do_jogo))
# A tibble: 5 × 2
  voto                                                       num_votos
  <chr>                                                          <dbl>
1 No necessary in-game text                                        182
2 Some necessary text - easily memorized or small crib sheet        26
3 Moderate in-game text - needs crib sheet or paste ups              1
4 Extensive use of text - massive conversion needed to be p…         0
5 Unplayable in another language                                     1

Para fechar essa seção, acho importante ressaltar que o processo que sugeri fazer aqui resume as informações em cada uma dessas três tabelas em torno de uma única quantidade - i.e., a informação com mais votos. No entanto, acredito ser possível criar algum tipo de embedding ou feature a partir das informações em cada uma dessas tabelas, que descreva de que forma a opinião dos usuários variou para aquele jogo. Isto talvez seja interessante para fazer uma caracterização mais refinada dos mesmos.

Metadados

O parser que deixei para o final é aquele relacionado aos metadados do jogo, que estavam dentro da tag link. Se você puder lembrar, essa é àquela tag que tinha umas 200 ocorrências no xml e, assim, já podemos imaginar a quantidade de informação que existe nela. De fato, quando parseamos essas informações através da função abaixo, vemos que existe uma diversidade de informações sobre cada jogo. Como o resultado dessa operação são várias list-columns dentro de um tibble, vamos precisar de mais um pouquinho de trabalho para deixar esse dado organizado.

## funcao para parsear os metadados do jogo
parser_metadados <- function(arquivo_xml) {
  # pega o arquivo HTML
  arquivo_xml %>% 
    # extrai todas as tags link
    xml_find_all(xpath = '*//link') %>% 
    # extrai todo os atributos dessas tags
    xml_attrs() %>% 
    # junta todos os atributos em uma tibble
    bind_rows() %>% 
    # organiza as colunas
    select(id_metadado = id, metadado = value, tipo_metadado = type) %>% 
    # removendo o padrao boardgame do tipo de metadado
    mutate(
      tipo_metadado = str_replace(string = tipo_metadado, 
                                  pattern = 'boardgame', replacement = 'tbl_')
      ) %>% 
    # aninhando informacoes pelo tipo de metadado
    nest(data = -tipo_metadado) %>% 
    # passando o dado para o formato largo
    pivot_wider(names_from = tipo_metadado, values_from = data)
}

# parseando os metadados
parser_metadados(arquivo_xml = content(x = xml_do_jogo))
# A tibble: 1 × 9
  tbl_category     tbl_mechanic tbl_family tbl_expansion tbl_compilation
  <list>           <list>       <list>     <list>        <list>         
1 <tibble [1 × 2]> <tibble [7 … <tibble [… <tibble [155… <tibble [1 × 2…
# … with 4 more variables: tbl_implementation <list>,
#   tbl_designer <list>, tbl_artist <list>, tbl_publisher <list>

Desempacotando os metadados

O resultado que obtivemos no parser dos metadados não é muito útil pois ele não está em um formato tidy. Apesar de termos uma informação diferente por coluna, ela está totalmente colapsada dentro de tibbles, o que dificulta (mas não impede) realizarmos muitas operações que seriam úteis para entender melhor os dados. Como a informação dentro de cada uma das list-columns deve ter uma estrutura diferente das demais, vamos começar tratando esse dado passando a base do formato largo para o formato longo, usando a função pivot_longer. A partir daí, vamos utilizar a função split para quebrar o tibble resultante em uma lista de tibbles por tipo de informação, desaninhando eles na sequência usando um unnest - eu descrevo o que são cada uma dessas informações e todas as demais no final desse post.

## expandindo cada uma das tabelas que contem multiplas informacoes sobre cada jogo
tabelas <- parser_metadados(arquivo_xml = content(x = xml_do_jogo)) %>% 
  # passando a base para o formato longo
  pivot_longer(cols = everything(), names_to = 'tabela', values_to = 'dados') %>% 
  # separando a base em listas de acordo com a dimensao
  split(.$tabela) %>% 
  # desaninhando cada tabela
  map(.f = unnest, cols = dados) %>% 
  # dropando a coluna do id da tabela
  map(.f = select, -tabela) %>% 
  # ordenando as tabelas por jogo em ordem alfabetica do metadado
  map(.f = arrange, metadado)
tabelas
$tbl_artist
# A tibble: 2 × 2
  id_metadado metadado       
  <chr>       <chr>          
1 12519       Cyrille Daujean
2 11886       Julien Delval  

$tbl_category
# A tibble: 1 × 2
  id_metadado metadado
  <chr>       <chr>   
1 1034        Trains  

$tbl_compilation
# A tibble: 1 × 2
  id_metadado metadado                        
  <chr>       <chr>                           
1 160069      Ticket to Ride: 10th Anniversary

$tbl_designer
# A tibble: 1 × 2
  id_metadado metadado    
  <chr>       <chr>       
1 9           Alan R. Moon

$tbl_expansion
# A tibble: 155 × 2
   id_metadado metadado                                              
   <chr>       <chr>                                                 
 1 185197      Alaska (fan expansion for Ticket to Ride)             
 2 184013      Alice in Wonderland (fan expansion for Ticket to Ride)
 3 120037      Alsace (fan expansion for Ticket to Ride)             
 4 189445      Ancient Greece (fan expansion for Ticket to Ride)     
 5 185198      Ancient Sicily (fan expansion for Ticket to Ride)     
 6 197040      Antarctica (fan expansion for Ticket to Ride)         
 7 252142      Antarctica (fan expansion for Ticket to Ride)         
 8 223979      Barsoom (fan expansion for Ticket to Ride)            
 9 211230      Belarus (fan expansion for Ticket to Ride)            
10 289366      Biblical Israel (fan expansion for Ticket to Ride)    
# … with 145 more rows

$tbl_family
# A tibble: 4 × 2
  id_metadado metadado                                      
  <chr>       <chr>                                         
1 64960       Components: Map (Continental / National scale)
2 61646       Continents: North America                     
3 14835       Country: USA                                  
4 17          Game: Ticket to Ride (Official)               

$tbl_implementation
# A tibble: 12 × 2
   id_metadado metadado                            
   <chr>       <chr>                               
 1 258140      Les Aventuriers du Rail Express     
 2 244525      Ticket to Ride Demo                 
 3 309113      Ticket to Ride: Amsterdam           
 4 14996       Ticket to Ride: Europe              
 5 205125      Ticket to Ride: First Journey (U.S.)
 6 225244      Ticket to Ride: Germany             
 7 276894      Ticket to Ride: London              
 8 21348       Ticket to Ride: Märklin             
 9 253284      Ticket to Ride: New York            
10 31627       Ticket to Ride: Nordic Countries    
11 202670      Ticket to Ride: Rails & Sails       
12 34127       Ticket to Ride: The Card Game       

$tbl_mechanic
# A tibble: 7 × 2
  id_metadado metadado                  
  <chr>       <chr>                     
1 2041        Card Drafting             
2 2912        Contracts                 
3 2875        End Game Bonuses          
4 2040        Hand Management           
5 2081        Network and Route Building
6 2661        Push Your Luck            
7 2004        Set Collection            

$tbl_publisher
# A tibble: 17 × 2
   id_metadado metadado                   
   <chr>       <chr>                      
 1 23043       ADC Blackfire Entertainment
 2 47848       Asmodee China              
 3 934         Bandai                     
 4 6784        Bergsala Enigma (Enigma)   
 5 34501       Boardgame Space            
 6 1027        Days of Wonder             
 7 2973        Edge Entertainment         
 8 15605       Galápagos Jogos            
 9 5530        Giochi Uniti               
10 8439        Happy Baobab               
11 18852       Hobby World                
12 8291        Korea Boardgames Co., Ltd. 
13 3218        Lautapelit.fi              
14 11107       Nordic Games GmbH          
15 7466        Rebel Sp. z o.o.           
16 33998       Siam Board Games           
17 9234        Swan Panasia Co., Ltd.     

Como podemos ver, todas essas informações já são tidy por si só, pois temos uma informação diferente por coluna e cada observação em uma linha distinta. Se o nosso objetivo for a manipulação e visualização de dados, então salvar cada uma dessas tabelas no formato em que estão já estaria ok. Por outro lado, se quisermos juntar essas informações para colocar todas as informações sobre um determinado jogo em uma única linha, usar elas para fazer um join acabaria replicando todas as informações em muitas linhas, o que não é desejável. Nesse contexto, teríamos duas opções para prevenir que isto ocorra, novamente dependendo do objetivo final que formos dar aos dados:

  1. Se a ideia for criar uma base para a análise de dados, então poderíamos fazer um one-hot-encoding de todas as informações e juntar todas as colunas. Isto, no entanto, geraria uma infinidade de colunas novas e uma matriz super esparsa1;
  2. Se quisermos apenas consolidar essas informações em uma única tabela, então basta que concatenemos cada uma delas em um único string por tibble e juntar tudo em uma tabela só. Para isso, podemos usar um paste0 dentro de um summarise para colapsar as informações de cada linha em um string só, seguido de um bind_rows para juntar cada tibble resultante linha a linha e, finalmente, um pivot_wider para colocar cada informação em uma coluna diferente. Para fins ilustrativos, apresento este tratamento abaixo.
## colocando as tabelas no formato tidy
tabelas_tidy <- tabelas %>% 
  # sumarizando todas as informacoes de forma a termos uma linha por jogo
  map(.f = summarise, metadado = paste0(unique(metadado), collapse = ';')) %>% 
  # colocando tudo em uma unica tabela
  bind_rows(.id = 'informacao') %>% 
  # removendo o prefixo tbl
  mutate(informacao = str_remove(string = informacao, pattern = 'tbl_')) %>% 
  # passando a base para o formato largo
  pivot_wider(names_from = informacao, values_from = metadado)
tabelas_tidy
# A tibble: 1 × 9
  artist category compilation designer expansion family implementation
  <chr>  <chr>    <chr>       <chr>    <chr>     <chr>  <chr>         
1 Cyril… Trains   Ticket to … Alan R.… Alaska (… Compo… Les Aventurie…
# … with 2 more variables: mechanic <chr>, publisher <chr>

Pronto! Já temos uma visão de como colocar as informações dos metadados em um único tibble no formato tidy, seja para consolidar tudo em uma tabela só ou para a análise de dados. Vamos fechar esse post criando uma função para consolidar todo o parser.

Colocando tudo junto

Todas as funções que criamos para parsear as informações recebem como input o xml do jogo e retornam um tibble, uma lista de tibbles ou um vetor com um único elemento. Portanto, vou criar uma única função para fazer o parser chamando todas essas outras funções que criamos, mas aproveitando para carregar o arquivo xml que queremos parsear dentro dela mesmo, facilitando a nossa vida. Com isso, essa função receberá como argumento apenas o path para o arquivo xml que foi salvo.

# funcao para parsear o arquivo xml inteiro do jogo
parser_do_jogo <- function(path_arquivo_xml) {
  
  ## lendo o arquivo xml
  xml_do_jogo <- read_xml(x = path_arquivo_xml)
  
  ## parseando o arquivo xml
  tibble(
    tbl_nomes             = list(parser_nome(arquivo_xml = xml_do_jogo)),
    tbl_comentarios       = list(parser_comentarios(arquivo_xml = xml_do_jogo)), 
    tbl_rankings          = list(parser_rankings(arquivo_xml = xml_do_jogo)), 
    tbl_votacao_idade     = list(parser_votacao_idade(arquivo_xml = xml_do_jogo)), 
    tbl_votacao_idioma    = list(parser_votacao_idioma(arquivo_xml = xml_do_jogo)),
    tbl_votacao_jogadores = list(parser_votacao_n_jogadores(arquivo_xml = xml_do_jogo))
  ) %>% 
    # juntando informacoes que ja estao no formato esperado
    bind_cols(
      parser_metadados(arquivo_xml = xml_do_jogo),
      descricao = parser_descricao(arquivo_xml = xml_do_jogo),
      total_comentarios = parser_comentarios_total(arquivo_xml = xml_do_jogo),
      parser_avaliacoes(arquivo_xml = xml_do_jogo),
      parser_informacoes(arquivo_xml = xml_do_jogo)
    )
}

## fazendo o parser do jogo
jogos <- parser_do_jogo(path_arquivo_xml = 'temporario/00009209.html')

## olhando a tabela
jogos
# A tibble: 1 × 36
  tbl_nomes         tbl_comentarios    tbl_rankings     tbl_votacao_ida…
  <list>            <list>             <list>           <list>          
1 <tibble [16 × 2]> <tibble [100 × 3]> <tibble [2 × 6]> <tibble [12 × 2…
# … with 32 more variables: tbl_votacao_idioma <list>,
#   tbl_votacao_jogadores <list>, tbl_category <list>,
#   tbl_mechanic <list>, tbl_family <list>, tbl_expansion <list>,
#   tbl_compilation <list>, tbl_implementation <list>,
#   tbl_designer <list>, tbl_artist <list>, tbl_publisher <list>,
#   descricao <chr>, total_comentarios <dbl>, usersrated <dbl>,
#   average <dbl>, bayesaverage <dbl>, stddev <dbl>, median <dbl>, …

Como podemos ver, temos todas as informações que já cobrimos até aqui nessa tabela, inclusive àquelas list-columns. Poderíamos salvar essa tabela como está, ou descartar àquelas list-columns e usar àquelas que preparamos acima, criar novas colunas ou fazer qualquer coisa que nos ocorrer - mas acredito que qualquer decisão daqui para a frente depende muito de como esses dados seriam usados. Deixo todas essas opções em aberto por aqui, mas mostrarei como fazer algumas delas em posts futuros, quando pretendo usar esses dados para gerar algumas visualizações, dashboards e análises. Um ponto importante a se notar é que temos apenas um jogo neste exemplo e, portanto, essa tabela acima tem uma só linha. Todavia, caso tivéssemos mais de um, jogo teríamos tantas linhas quanto fossem os jogos, e ainda poderíamos colocar uma coluna com o identificador numérico do jogo para referência.

Eu mostro como generalizar essa função para vários jogos através do o código que acompanha este post, acessível por esse link.

Dicionário de Dados

Como pretendo usar esses dados em mais de um post, vou deixar aqui um pequeno dicionário para explicar as tabelas e o que temos de informação em cada uma delas. Vou usar como referência as informações que estão dentro do objeto jogos, uma vez que o que vier dele vai ser o output padrão do parser que criamos.

Conclusões

A ideia deste post foi criar o scrapper das informações dos jogos de tabuleiro disponíveis no portal do BoardGameGeek, fazendo uso de sua API XML para isso. Essa API é bastante simples e fácil de usar, agilizando o trabalho de obtenção dos dados, apesar das dificuldades impostas para o seu processamento e organização. Neste intuito, desenvolvemos uma série de funções para fazer a requisição destes dados e parseá-los, de forma a chegarmos em uma estrutura de dados tal que tenhamos uma linha de um data.frame/tibble com as informações de cada jogo. A partir desta estrutura podemos então pensar em extensões do processamento de dados que melhor atendam aos objetivos de visualização e análise de dados, algo que acabaremos focando em posts futuros. Neste contexto, acredito que esse post ficará mais como uma referência futura sobre o método de obtenção dos dados do que qualquer outra coisa em si.

Um ponto importante é que resolvi explorar neste post apenas o endpoint com as informações sobre cada jogo, e nem toquei nos demais. Assim, algumas outras possibilidades permanecem inexploradas (e.g., raspar as informações sobre as coleções de cada usuário), mas podem se provar úteis para outras finalidades relacionadas ao aprendizado.

Possíveis Extensões

A obtenção desses dados é um passo bastante importante para seguirmos em frente com diversas questões que já foram colocadas em outros posts, e muitas outras que podemos começar a pensar agora que sabemos que dados que temos em mãos:

Dúvidas, sugestões ou críticas? É só me procurar pelo e-mail ou GitHub!


  1. A solução aqui seria basicamente um pivot_wider em cada tibble, seguida de um bind_cols entre elas e delas com todas as demais informações.↩︎

Corrections

If you see mistakes or want to suggest changes, please create an issue on the source repository.

Reuse

Text and figures are licensed under Creative Commons Attribution CC BY 4.0. Source code is available at https://github.com/nacmarino/codex/, unless otherwise noted. The figures that have been reused from other sources don't fall under this license and can be recognized by a note in their caption: "Figure from ...".

Citation

For attribution, please cite this work as

Marino (2022, Jan. 23). Codex: Interagindo com a API XML do BoardGameGeek. Retrieved from https://nacmarino.github.io/codex/posts/2022-01-23-interagindo-com-a-api-do-boardgamegeek/

BibTeX citation

@misc{marino2022interagindo,
  author = {Marino, Nicholas},
  title = {Codex: Interagindo com a API XML do BoardGameGeek},
  url = {https://nacmarino.github.io/codex/posts/2022-01-23-interagindo-com-a-api-do-boardgamegeek/},
  year = {2022}
}