Raspagem de Dados com Python e BeautifulSoup

Josenildo Costa da Silva
Machina Sapiens
Published in
27 min readJan 8, 2018

Nota do tradutor: Post original em inglês por Alexandru Olteanu no dataquest.io (link)

As principais fontes de dados para projetos de data science são geralmente bancos de dados SQL e NoSQL, APIs, ou arquivos CSV. Entretanto, nem sempre pode-se encontrar uma fonte dados no tópico que você está interessado, bancos de dados não são mantidos atualizados e APIs são caras ou tem limites de utilização.

Se os dados que você está procurando estão em uma página web, contudo, a solução para todos estes problemas podem ser a raspagem de dados (web scraping).

Neste tutorial, você irá aprender como extrair dados de multiplas páginas web com Python utilizando BeautifulSoup e requests. Vamos então realizar algumas análises simples utilizando pandas e matplotlib.

Você deve ter algum conhecimento básico de HTML, uma boa noção básica de Python e uma ideia superficial do que seja extração de dados na web. Se você não se sente confortável com alguns destes tópicos, recomendo este tutorial para iniciantes em raspagem de dados na web.

Raspando dados de mais de 2000 filmes

Vamos analisar a distribuição de avaliações de filmes do IMDB e Metacritic para ver se encontramos alguma coisa interessante. Para isto, primeiro vamos extrair dados de mais de 2000 filmes.

É essencial que você defina o objetivo da raspagem de dados desde o início. Escrever um script de raspagem pode levar muito tempo, especialmente se queremos extrair dados de mais de uma página da web. É bom evitar gastar horas escrevendo um script que extrai dados que nós realmente não precisamos.

Definindo quais páginas serão raspadas

Uma vez definido nosso objetivo, precisamos identificar um conjunto de páginas que iremos raspar.

O ideal é identificar uma combinação de páginas que exija poucas requisições. Uma requisição é o que acontece cada vez que acessamos uma página web. Nós “requisitamos” o conteúdo de uma página do servidor. Quanto mais requisições fizermos, mais tempo o script levará para completar, e exigirá mais trabalho do servidor.

Uma maneira de conseguir todas as informações que queremos é fazer uma lista de nomes de filmes e utilizá-la para acessar a página da web do filme tanto no IMDB quanto no Metacritic.

Como queremos mais de 2000 avaliações do IMDB e Metacritic, teremos que pelo menos 4000 requisições. Se levarmos um segundo por requisição, nosso script irá precisar de pouco mais de uma hora para realizar 4000 requisições. Portanto, é necessário encontrar uma maneira mais eficiente para extrair os dados.

Explorando o website do IMDB, podemos descobrir uma maneira de reduzir pela metade o número de requisições. As avaliações do Metacritic são mostrados na página do filme, portanto, podemos extrair ambas as avaliações com uma mesma requisição:

Se investigarmos o site do IMDB um pouco mais, vamos descobrir a página abaixo. Ela contem todas as informações que precisamos para 50 filmes. De acordo com nosso objetivo, isto significa que teremos apenas umas 40 requisições, o que é 100 menos que nossa primeira opção. Vamos explorar em mais detalhes esta opção.

Identificando a estrutura da URL

Nosso desafio agora é entender a lógica da URL à medida em que mudam as páginas que queremos raspar. Se não compreendermos esta lógica o suficiente não poderemos implementar em código para extração dos dados.

Na página avançada do IMDB, você pode navegar nos filmes por ano:

Vamos visualizar o ano 2017, ordenar os filmes na primeira página por número de votos e então mudaremos para a próxima página. Iremos chegar nesta página web, que tem esta URL:

Na imagem acima, você observa que esta URL tem vários parâmetros após o ponto de interrogação:

  • release_date - mostra apenas os filmes lançados em um ano específico.
  • sort - Ordena os filmes na página. sort=num_votes,desc signfica ordenar por número de votos em ordem decrescente.
  • page - Especifica o número da página.
  • ref_ - Nos leva à próxima página ou à página anterior. A referência é a página na qual estamos atualmente. Os dois valores possíveis sãoadv_nxte adv_prv. Eles significam avançar para próxima página e avançar para página anterior, respectivamente.

Se você navegar por estas páginas e observar a URL, irá notar que apenas os valores dos parâmetros mudam. Isto significa que podemos escrever um script seguindo a lógica das mudanças e com isto realizar ainda menos requisições para extrair os dados.

Vamos começar o script requisitando o conteúdo de uma única página web:

http://www.imdb.com/search/title?release_date=2017&sort= num_votes,desc&page=1

O código abaixo faz o seguinte:

  • Importa a função get() do módulo requests.
  • Atribui um endereço da página web à variável url.
  • Realiza a requisição do conteúdo da página web usando get(), e armazena a resposta do servidor na variávelresponse.
  • Imprime uma amostra da responseacessando o atributo .text(response agora é uma objeto Response).
from requests import geturl = 'http://www.imdb.com/search/title?release_date=2017&sort=num_votes,desc&page=1'response = get(url)
print(response.text[:500])
<!DOCTYPE html>
<html
xmlns:og="http://ogp.me/ns#"
xmlns:fb="http://www.facebook.com/2008/fbml">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="apple-itunes-app" content="app-id=342792525, app-argument=imdb:///?src=mdot">


<script type="text/javascript">var ue_t0=window.ue_t0||+new Date();</script>
<script type="text/javascript">
var ue_mid = "A1EVAM02EL8SFB";

Compreendendo a estrutura HTML de uma única página

Como você pode ver na primeira linha de response.txt, o servidor nos envia um documento HTML. Este documento descreve a estrutura geral desta página web, além do seu conteúdo específico (que torna única esta página em particular).

Todas as páginas que queremos processar tem a mesma estrutura geral. Isto significa que elas vão ter a mesma estrutura geral HTML. Assim, para escrever o script, será suficiente que compreendamos a estrutura HTML de uma única página. Para fazer isto, iremos utilizar as ferramentas de desenvolvedor do navegador.

Se você utiliza o Chrome, clique com o botão direito no elemento de interesse na página web, e depois clique em inspect. Este comando mostra a linha no código HTML que corresponde àquele elemento:

Clique com o botão direito no nome do filme, e depois escolha o menu Inspect. A linha HTML destacada em cinza corresponde ao nome do filme como o usuário o vê na página web.

Você também pode fazer isso no DevTools do Firefox e do Safari.

Note que toda a informação sobre um filme, incluindo o poster, está contido em uma tag div.

Há muitas linhas HTML aninhada em cada tag div. Você pode explorá-las clicando as setas cinza à esquerda das linhas HTML correspondente a cada div. Em cada uma destas tags aninhadas encontramos a informação necessária, como por exemplo a avaliação de um filme.

Há 50 filmes por páginas. Portanto, haverá um container divpara cada um deles. Vamos extrair todos estes 50 containers fazendo o parser do documento HTML de nossa requisição anterior.

Usando BeautifulSoup para analisar o conteúdo HTML

Para analisar o conteúdo do documento HTML e extrair os 50 container div, vamos utilizar um módulo Python chamado BeautifulSoup, atualmente o módulo de raspagem de dados em Python mais conhecido.

No código a seguir iremos:

  • Importar o criador da classeBeautifulSoupdo pacote bs4.
  • Analisar response.textcriando um objeto BeautifulSoup, e atribuido-o a html_soup. O argumento html.parserindica que queremos fazer a análise usando o analisador HTML embutido do Python.
from bs4 import BeautifulSouphtml_soup = BeautifulSoup(response.text, 'html.parser')
type(html_soup)
bs4.BeautifulSoup

Antes de extrair os 50 containers div, precisamos descobrir o que os distingue dos outros elementos div na página. Geralmente, a principal diferença está no atributo class. Se inspecionarmos as linhas HTML do container de interesse, iremos notar que o atributo class tem dois valores: lister-item e mode-advanced. Esta combinação é única para estes containers div. Podemos verificar que isto é verdade fazendo uma busca (Ctrl+F). Temos 50 containers, então, esperamos encontrar apenas 50 ocorrencias desta busca:

Agora, vamos utilizar o método find_all() para extrair todos os containers div que tenham um atributo class com lister-item mode advanced:

movie_containers = html_soup.find_all('div', class_ = 'lister-item mode-advanced')
print(type(movie_containers))
print(len(movie_containers))
<class 'bs4.element.ResultSet'>
50

O método find_all() retornou um objeto ResultSet com uma lista contando todos os 50 div que nos interessam.

Vamos selecionar apenas os primeiro container, e extrair, um por um, os elementos com informações que procuramos:

  • O nome do filme.
  • O ano de lançamento.
  • A avaliação IMDB.
  • A avaliação Metascore.
  • O número de votos.

Extraindo dados de um único filme

Podemos acessar o primeiro container, que contém informações sobre um único filme, utilizando a notação de lista no movie_containers.

first_movie = movie_containers[0]
first_movie
<div class="lister-item mode-advanced">
<div class="lister-top-right">
<div class="ribbonize" data-caller="filmosearch" data-tconst="tt3315342"></div>
</div>
<div class="lister-item-image float-left">
<a href="/title/tt3315342/?ref_=adv_li_i"> <img alt="Logan" class="loadlate" data-tconst="tt3315342" height="98" loadlate="https://images-na.ssl-images-amazon.com/images/M/MV5BMjQwODQwNTg4OV5BMl5BanBnXkFtZTgwMTk4MTAzMjI@._V1_UX67_CR0,0,67,98_AL_.jpg" src="http://ia.media-imdb.com/images/G/01/imdb/images/nopicture/large/film-184890147._CB522736516_.png" width="67"/>
</a> </div>
<div class="lister-item-content">
<h3 class="lister-item-header">
<span class="lister-item-index unbold text-primary">1.</span>
<a href="/title/tt3315342/?ref_=adv_li_tt">Logan</a>
<span class="lister-item-year text-muted unbold">(2017)</span>
</h3>
<p class="text-muted ">
<span class="certificate">R</span>
<span class="ghost">|</span>
<span class="runtime">137 min</span>
<span class="ghost">|</span>
<span class="genre">
Action, Drama, Sci-Fi </span>
</p>
<div class="ratings-bar">
<div class="inline-block ratings-imdb-rating" data-value="8.3" name="ir">
<span class="global-sprite rating-star imdb-rating"></span>
<strong>8.3</strong>
</div>
<div class="inline-block ratings-user-rating">
<span class="userRatingValue" data-tconst="tt3315342" id="urv_tt3315342">
<span class="global-sprite rating-star no-rating"></span>
<span class="rate" data-no-rating="Rate this" data-value="0" name="ur">Rate this</span>
</span>
<div class="starBarWidget" id="sb_tt3315342">
<div class="rating rating-list" data-auth="" data-ga-identifier="" data-starbar-class="rating-list" data-user="" id="tt3315342|imdb|8.3|8.3|||search|title" itemprop="aggregateRating" itemscope="" itemtype="http://schema.org/AggregateRating" title="Users rated this 8.3/10 (320,428 votes) - click stars to rate">
<meta content="8.3" itemprop="ratingValue"/>
<meta content="10" itemprop="bestRating"/>
<meta content="320428" itemprop="ratingCount"/>
<span class="rating-bg"> </span>
<span class="rating-imdb " style="width: 116.2px"> </span>
<span class="rating-stars">
<a href="/register/login?why=vote&amp;ref_=tt_ov_rt" rel="nofollow" title="Register or login to rate this title"><span>1</span></a>
<a href="/register/login?why=vote&amp;ref_=tt_ov_rt" rel="nofollow" title="Register or login to rate this title"><span>2</span></a>
<a href="/register/login?why=vote&amp;ref_=tt_ov_rt" rel="nofollow" title="Register or login to rate this title"><span>3</span></a>
<a href="/register/login?why=vote&amp;ref_=tt_ov_rt" rel="nofollow" title="Register or login to rate this title"><span>4</span></a>
<a href="/register/login?why=vote&amp;ref_=tt_ov_rt" rel="nofollow" title="Register or login to rate this title"><span>5</span></a>
<a href="/register/login?why=vote&amp;ref_=tt_ov_rt" rel="nofollow" title="Register or login to rate this title"><span>6</span></a>
<a href="/register/login?why=vote&amp;ref_=tt_ov_rt" rel="nofollow" title="Register or login to rate this title"><span>7</span></a>
<a href="/register/login?why=vote&amp;ref_=tt_ov_rt" rel="nofollow" title="Register or login to rate this title"><span>8</span></a>
<a href="/register/login?why=vote&amp;ref_=tt_ov_rt" rel="nofollow" title="Register or login to rate this title"><span>9</span></a>
<a href="/register/login?why=vote&amp;ref_=tt_ov_rt" rel="nofollow" title="Register or login to rate this title"><span>10</span></a>
</span>
<span class="rating-rating "><span class="value">8.3</span><span class="grey">/</span><span class="grey">10</span></span>
<span class="rating-cancel "><a href="/title/tt3315342/vote?v=X;k=" rel="nofollow" title="Delete"><span>X</span></a></span>
</div>
</div>
</div>
<div class="inline-block ratings-metascore">
<span class="metascore favorable">77 </span>
Metascore
</div>
</div>
<p class="text-muted">
In the near future, a weary Logan cares for an ailing Professor X somewhere on the Mexican border. However, Logan's attempts to hide from the world and his legacy are upended when a young mutant arrives, pursued by dark forces.</p>
<p class="">
Director:
<a href="/name/nm0003506/?ref_=adv_li_dr_0">James Mangold</a>
<span class="ghost">|</span>
Stars:
<a href="/name/nm0413168/?ref_=adv_li_st_0">Hugh Jackman</a>,
<a href="/name/nm0001772/?ref_=adv_li_st_1">Patrick Stewart</a>,
<a href="/name/nm6748436/?ref_=adv_li_st_2">Dafne Keen</a>,
<a href="/name/nm2933542/?ref_=adv_li_st_3">Boyd Holbrook</a>
</p>
<p class="sort-num_votes-visible">
<span class="text-muted">Votes:</span>
<span data-value="320428" name="nv">320,428</span>
<span class="ghost">|</span> <span class="text-muted">Gross:</span>
<span data-value="226,264,245" name="nv">$226.26M</span>
</p>
</div>
</div>

Como se pode ver, o conteúdo HTML de um container é muito longo. Para encontrar a linha específica de cada filme, vamos utilizar o DevTools novamente.

O nome do filme

Vamos começar com o nome do filme e localizar a linha HTML correspondente utilizando o DevTools. Observe que o nome está em uma tag de âncora (<a>). Esta tag está aninhada em uma tag de cabeçalho(<h3>). A tag <h3> está aninhada em uma tag div. Esta tag div é a terceira de uma série de div aninhadas no container do primeiro filme. Armazenamos o conteúdo deste container na variável first_movie.

A variável first_movie é um objeto Tag, e várias outras tags HTML dentro dela são armazenadas como seus atributos. Podemos acessá-los do mesmo modo que acessamos qualquer outro atributo de um objeto em Python. Contudo, utilizar o nome de uma tag irá selecionar apenas a primeira tag com este nome. Se executarmos firts_movie.div , receberemos apenas o conteúdo da primeira tag div :

first_movie.div<div class="lister-top-right">
<div class="ribbonize" data-caller="filmosearch" data-tconst="tt3315342"></div>
</div>

Acessar a primeira tag âncora ( <a>) não nos dá o nome do filme. O primeiro <a> está em algum lugar do segundo div :

first_movie.a<a href="/title/tt3315342/?ref_=adv_li_i"> <img alt="Logan" class="loadlate" data-tconst="tt3315342" height="98" loadlate="https://images-na.ssl-images-amazon.com/images/M/MV5BMjQwODQwNTg4OV5BMl5BanBnXkFtZTgwMTk4MTAzMjI@._V1_UX67_CR0,0,67,98_AL_.jpg" src="http://ia.media-imdb.com/images/G/01/imdb/images/nopicture/large/film-184890147._CB522736516_.png" width="67"/>
</a>

Por outro lado, acessar o primeiro <h3> nos leva bem próximos do que queremos:

first_movie.h3<h3 class="lister-item-header">
<span class="lister-item-index unbold text-primary">1.</span>
<a href="/title/tt3315342/?ref_=adv_li_tt">Logan</a>
<span class="lister-item-year text-muted unbold">(2017)</span>
</h3>

A partir daí, podemos utilizar a notação de atributo para acessar o primeiro <a> dentro da tag <h3> :

first_movie.h3.a<a href="/title/tt3315342/?ref_=adv_li_tt">Logan</a>

Basta acessar o texto da tag <a> :

first_name = first_movie.h3.a.text
first_name
'Logan'

O ano de lançamento do filme

A próxima etapa é extrair o ano do filme. Esta informação está armazenada na tag <spam> abaixo da tag <a> que contém o nome.

A notação por ponto (. ) acessa apenas o primeiro elemento span . Vamos procurar pela marca distintiva do segundo span . Para isto, utilizaremos o método find() que quase a mesma coisa que find_all , exceto que ele retorna apenas a primeira ocorrência. Na realidade, find() é equivalente a find_all(limit = 1) . Neste caso, o argumento limit informa que desejamos apenas a primeira ocorência.

A marca distintiva são os valores lister-item text-muted unbold atribuido ao atributo class . Por isso, buscamos o primeiro <span> com estes valores dentro da tag <h3> :

first_year = first_movie.h3.find('span', class_ = 'lister-item-year text-muted unbold')
first_year
<span class="lister-item-year text-muted unbold">(2017)</span>

Com o resultado, basta acessar seu texto utilizando a notação de atributo:

first_year = first_year.text
first_year
'(2017)'

Podemos limpar esta saída e converter para inteiro facilmente. Mas se você explorar mais páginas, irá notar que alguns filmes apresentam valores de ano imprevisíveis tais como (2017(I) ou ainda (2015)(V). É mais eficiente fazer a limpeza depois da extração, quando já sabemos todos os valores.

A avaliação IMDB

Nosso objetivo agora é extrair a avaliação IMDB para o primeiro filme.

Há várias maneiras de se fazer isto, mas vamos tentar primeiro a mais fácil. Se você inspecionar as avaliações IMDB com o DevTools, notará que a avaliação está contida em uma tag <strong> .

Assim, podemos utilizar a notação de atributo e torcer para que o primeiro <strong> seja o que contém a avaliação.

first_movie.strong<strong>8.3</strong>

Excelente! Vamos acessar o texto, converter para float e atribuí-lo para a variável first_imdb :

first_imdb = float(first_movie.strong.text)
first_imdb
8.3

O Metascore

Inspecionando o Metascore com o DevTools, notamos que ele está em uma tag span .

Neste caso a notação de atributos não é uma opção. Há muitas tags <span> antes dela. Há uma logo antes da tag <strong> . Melhor utilizar os valores distintivos do atributo class ( metascore favorable ).

Note que se você copiar-e-colar os valores da janela do DevTools, haverá dois espaços em branco entre metascore e favorable . Certifique-se de que tenha apenas um espaço em branco no argumento class_ quando você chamar a função. Do contrário, find() não irá encontrar ocorrência alguma.

first_mscore = first_movie.find('span', class_ = 'metascore favorable')first_mscore = int(first_mscore.text)
print(first_mscore)
77

O valor favorable indica um Metascore alto e torna verde a cor de fundo da avaliação. Os outros dois valores possíveis são unfavorable e mixed. Entretanto, o que é específico para todas as avaliações Metascore é o valor metascore. Este é o único que iremos utilizar quando escrevermos o script para a página inteira.

O número de votos

O número de votos pode ser encontrado na tag <span>. Sua marca distintiva é um atributo name com o valor nv.

O atributo name é diferente do atributo class. Utilizando o BeautifulSoup podemos acessar atributos de quaisquer elementos. Os métodos find() e find_all() possuem um parâmetro chamado attrs com o qual podemos informar os atributos e os valores que queremos buscar, como um dicionário:

first_votes = first_movie.find('span', attrs = {'name':'nv'})
first_votes
<span data-value="320428" name="nv">320,428</span>

É possivel utilizar a notação .text para acessar o conteúdo da tag <span>. Uma alternativa melhor, entretanto, é acessar o valor do atributo data-value. Deste modo, podemos converter o valor extraído para int sem ter que remover a vírgula.

Você pode tratar um objeto Tag como um dicionário. Os atributos HTML são as chaves do dicionário. Os valores dos atributos HTML são os valores das chaves do dicionário. Desta forma podemos acessar o valor do atributo data-value:

first_votes['data-value']'320428'

Vamos converter este valor para inteiro, e atribuí-lo para first_votes:

first_votes = int(first_votes['data-value'])

Pronto! Já temos tudo que precisamos para escrever um script para extrair dados de uma página única.

O script para uma página

Antes de juntar tudo o que fizemos até aqui, temos que garantir que iremos extrair dados apenas dos containers que tenham um Metascore.

Por isto, precisamos adicionar uma condição para ignorar filmes que não possuam um Metascore.

Utilizando DevTools outra vez, descobrimos que a seção Metascore está contida em uma tag <div>. O atributo class tem dois valores: inline-block e ratins-metascore. A marca distintiva é claramente ratings-metascore.

O método find() pode nos dar todos os containers de filmes que possuam um div com esta marca distintiva. Quando o find() não encontra algo, ele retorna um objetoNone. Este resultado pode ser utilizado em um comando if para decidir se um filme será extraído ou não.

Para testar, vamos aplicar find() a um container que não possui um Metascore e ver o que ele retorna.

Nota do autor: quando este código foi executado pela primeira vez, o oitavo container não possuia um Metascore. Entretanto, isto pode mudar com o tempo, porque o número de votos muda constatemente para cada filme. Para obter os mesmos resultados que os relatados aqui, você deve procurar um container que não tenha um metascore quando você executar este código.

eighth_movie_mscore = movie_containers[7].find('div', class_ = 'ratings-metascore')
type(eighth_movie_mscore)
NoneType

Vamos agora juntar todo o código apresentado e comprimir o máximo possível, mantendo a legibilidade. No código abaixo iremos:

  • Declarar as variáveis (listas) que irão armazenar os dados extraídos.
  • Visitar cada container no movie_containers (a variável que contém todos os containers dos 50 filmes).
  • Extrair os pontos de dados de interesse apenas se o container possuir um Metascore.
# Lists to store the scraped data in
names = []
years = []
imdb_ratings = []
metascores = []
votes = []
# Extract data from individual movie container
for container in movie_containers:

# If the movie has Metascore, then extract:
if container.find('div', class_ = 'ratings-metascore') is not None:

# The name
name = container.h3.a.text
names.append(name)

# The year
year = container.h3.find('span', class_ = 'lister-item-year').text
years.append(year)

# The IMDB rating
imdb = float(container.strong.text)
imdb_ratings.append(imdb)

# The Metascore
m_score = container.find('span', class_ = 'metascore').text
metascores.append(int(m_score))

# The number of votes
vote = container.find('span', attrs = {'name':'nv'})['data-value']
votes.append(int(vote))

Vamos verificar os dados que coletamos até aqui. Pandas é uma biblioteca que ajuda a verificar se extraímos os dados corretamente.

import pandas as pdtest_df = pd.DataFrame({'movie': names,
'year': years,
'imdb': imdb_ratings,
'metascore': metascores,
'votes': votes})
print(test_df.info())
test_df
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 32 entries, 0 to 31
Data columns (total 5 columns):
imdb 32 non-null float64
metascore 32 non-null int64
movie 32 non-null object
votes 32 non-null int64
year 32 non-null object
dtypes: float64(1), int64(2), object(2)
memory usage: 1.3+ KB
None
imdb metascore movie votes year
0 8.3 77 Logan 320428 (2017)
1 8.1 67 Guardians of the Galaxy Vol. 2 175443 (2017)
2 8.1 76 Wonder Woman 152067 (2017)
3 7.7 75 John Wick: Chapter 2 140784 (2017)
4 7.5 65 Beauty and the Beast 137713 (2017)
5 7.8 84 Get Out 136435 (I) (2017)
6 6.8 62 Kong: Skull Island 112012 (2017)
7 7.0 56 The Fate of the Furious 97690 (2017)
8 6.8 65 Alien: Covenant 88697 (2017)
9 6.7 54 Life 80897 (I) (2017)
10 7.0 39 Pirates of the Caribbean: Dead Men Tell No 77268 (2017)
11 6.6 52 Ghost in the Shell 68521 (2017)
12 7.4 75 The LEGO Batman Movie 61263 (2017)
13 5.2 42 xXx: Return of Xander Cage 50697 (2017)
14 4.6 33 Fifty Shades Darker 50022 (2017)
15 7.4 67 T2 Trainspotting 48134 (2017)
16 6.3 44 Power Rangers 44733 (2017)
17 5.8 34 The Mummy 34171 (2017)
18 6.4 50 The Boss Baby 32976 (2017)
19 6.6 43 A Dog’s Purpose 29528 (2017)
20 4.5 25 Rings 20913 (2017)
21 5.8 37 Baywatch 20147 (2017)
22 6.4 33 The Space Between Us 19044 (I) (2017)
23 5.3 28 Transformers: The Last Knight 17727 (2017)
24 6.1 56 War Machine 16740 (2017)
25 5.7 37 Fist Fight 16445 (2017)
26 7.7 60 Gifted 14819 (2017)
27 7.0 75 I Don’t Feel at Home in This World Anymore 14281 (2017)
28 5.5 34 Sleepless 13776 (III) (2017)
29 6.3 55 The Discovery 13207 (2017)
30 6.4 58 Before I Fall 13016 (2017)
31 8.5 26 The Ottoman Lieutenant 12868 (2017)

Tudo conforme esperado!

Uma observação. Se você executar este código em um país onde o Inglês não é a principal língua, pode acontecer que alguns nomes de filmes serão traduzidos na língua principal do país.

Muito provavelmente isto acontece porque os servidores inferem sua localização a partir do seu endereço de IP. Mesmo que você esteja em um país onde o Inglês é a língua principal, você ainda pode ter conteúdo traduzido. Isto pode acontecer ser você estiver utilizando uma VPN durante as requisições GET.

Se você observar isto, utilize os seguintes valores no parâmetro headers na função get():

headers = {"Accept-Language": "en-US, en;q=0.5"}

Assim, você informa ao servidor algo como “Eu quero o conteúdo em Inglês Americano (en-US). Se en-US não estiver disponível, então pode ser outros tipos de inglês (en), mas não tanto quanto en-US”. O parâmetros q indica o grau de preferência por uma língua. Se não for especificado, é assumido o valor 1 por default, como no caso de en-US. Você pode ler mais sobre isto, aqui.

Agora, vamos escrever o script para todas as páginas que queremos extrair.

O script para múltiplas páginas

Extrair dados de multiplas páginas é um pouco mais complicado. Vamos partir do script para uma página e adicionar mais três coisas:

  1. Fazer todas as requisições a partir de um laço.
  2. Controlar a velocidade do laço para evitar bombardear o servidor com muitas requisições por segundo.
  3. Monitorar o laço enquanto ele executa.

Iremos extrair os dados das primeiras 4 páginas para cada ano no intervalo de 2000 a 2017. Serão 4 páginas por ano em um período de 18 anos, totalizando 72 páginas. Cada página tem 50 filmes, então iremos extrair dados de no máximo 3600 filmes. Como nem todos os filmes tem Metascore, o número será menor que isto. Ainda assim, é bem provável que iremos receber informações de mais de 2000 filmes.

Alterando os parâmetros da URL

Como já mostrado anteriormente, a URL segue uma lógica a cada mudança de página.

À medida que fazemos requisições, temos que variar os valores de apenas dois parâmetros da URL: os parâmetrosrelease_date e page. Vamos preparar os parâmetros que precisamos para o próximo laço. No código abaixo iremos:

  • Criar uma lista chamada pages, e populá-la com as strings correpondentes às 4 primeiras páginas.
  • Criar uma lista chamada years_url e populá-la com as strings correspondentes aos anos 2000 até 2017.
  • Create a list called years_url and populate it with the strings corresponding to the years 2000-2017.
pages = [str(i) for i in range(1,5)]
years_url = [str(i) for i in range(2000,2018)]

Controlando a velociadade da extração

Controlar a velocidade da extração é benéfico para nós e para o website de onde estamos fazendo a raspagem de dados. Se evitarmos realizar dezenas de requisições por segundo, haverá muito menos chance de que nosso endereço de IP seja banido. Também evitamos causar interrupção do serviço do website do qual estamos extraindo os dados, permitindo que o website também responda a outras requisições.

Para controlar a velocidade do laço iremos utilizar a funçãosleep() do módulo timedo Python. Esta função irá pausar a execução do laço por uma quantidade especificada de segundos.

Para simular o comportamento humano, iremos variar a quandidade de tempo de espera entre as requisições utilizando a função randint() do módulo random do Python. A função randint() gera inteiros aleatoriamente em um intervalo especificado.

Portanto, vamos importar estas duas funções com o código abaixo:

from time import sleep
from random import randint

Monitorando o laço durante a execução

Considerando que estamos raspando dados de 72 páginas, seria bom ter um modo de monitorar o processo de extração enquanto ele está em andamento. Esta função é realmente opcional, mas pode ser muito útil durante o processo de testes e depuração. Além disso, quanto maior o número de páginas, mais útil se torna o monitoramento. Se você vai fazer a raspagem de centenas ou milhares de páginas em uma única execução de código, eu diria que o monitoramento é essencial.

Em nosso script iremos monitorar os seguintes parâmetros:

  • A frequência (velocidade) das requisições, para garantirmos que nosso programa não sobrecarrege o servidor.
  • O número de requisições, para que possamos parar o laço quando o número máximo de requisições for atingido.
  • O código de status das requisições, para que possamos ter certeza que o servidor está respondendo de modo apropriado.

Para calcular a frequência vamos dividir o número de requisições pelo tempo decorrido desde a última requisição. Este procedimento é similar ao cálculo da velocidade de um carro — dividimos a distância pelo tempo necessário para percorrer esta distância. Vamos testar esta técnica de monitoramento em pequena escala inicialmente. No código abaixo iremos:

  • Definir o tempo inicial utilizando a funçãotime() do módulo time, e atribuir o valor à variável start_time.
  • Atribuir 0 para a variável requests que será utilizada para contar o número de requisições.
  • Iniciar um laço, e em cada iteração:
  • Simula uma requisição.
  • Incremetar o número de requisições por 1.
  • Pausar o laço por um intervalo de 8 a 15 segundos.
  • Calcular o tempo decorrido desde a primeira requisição e atribuir o valor à variável elapsed_time.
  • Imprimir o número de requisições e a frequência.
from time import timestart_time = time()
requests = 0
for _ in range(5):
# A request would go here
requests += 1
sleep(randint(1,3))
elapsed_time = time() - start_time
print('Request: {}; Frequency: {} requests/s'.format(requests, requests/elapsed_time))
Request: 1; Frequency: 0.49947650463238624 requests/s
Request: 2; Frequency: 0.4996998027377252 requests/s
Request: 3; Frequency: 0.5995400143227362 requests/s
Request: 4; Frequency: 0.4997272043465967 requests/s
Request: 5; Frequency: 0.4543451628627026 requests/s

Como vamos fazer 72 requisições, a quantidade de informações na saída irá se acumular. Para evitar isto, vamos limpar a saída a cada iteração e substituí-la com informação sobre a requisição mais recente. Para conseguirmos isto, vamos utilizar a função clear_output()do módulo core.display. Vamos definir o parâmetro wait da clear_output() comoTrue para que a substituição da saída atual aguarde até que uma nova saída esteja pronta.

from IPython.core.display import clear_outputstart_time = time()
requests = 0
for _ in range(5):
# A request would go here
requests += 1
sleep(randint(1,3))
current_time = time()
elapsed_time = current_time - start_time
print('Request: {}; Frequency: {} requests/s'.format(requests, requests/elapsed_time))
clear_output(wait = True)
Request: 5; Frequency: 0.6240351700607663 requests/s

A saída acima é o que você vai ver quando o laço está em execução. A animação abaixo dá um ideia de como se comporta durante a execução do laço:

Utilizaremos o código de status para que o programa nos avise se alguma coisa der errado. Uma requisição bem-sucedida é indicada pelo código 200. Vamos utilizar a função warn() do módulo warning para disparar um aviso se o código de status não for 200.

from warnings import warnwarn("Warning Simulation")/Users/joshuadevlin/.virtualenvs/everday-ds/lib/python3.4/site-packages/ipykernel/__main__.py:3: UserWarning: Warning Simulation
app.launch_new_instance()

Escolhemos um aviso ao invés de interromper o laço porque há uma boa possibilidade de que iremos raspar dados suficiente mesmo que alguma coisa dê errado em uma das requisições. Vamos interromper o laço apenas se o limite máximo de requisições for atingido.

Juntando tudo

Vamos juntar todas as peças de código que escrevemos até aqui! No código seguinte iremos começar fazendo o seguinte:

  • Redeclarar as listas de variáveis para que fiquem vazias novamente.
  • Preparar o monitoramento do laço.

Depois iremos:

  • Iterar pela listayears_url para variar o parâmetro release_date da URL.
  • Para cada elemento de years_url, iterar pela lista pages para variar o parâmetro page na URL.
  • Realizar a requisição GET no laço de pages (e passar o valor correto para o parâmetro headers para que recupere apenas conteúdo em inglês).
  • Pausar o laço por um intervalo de tempo de 8 a 15 segundos.
  • Monitorar cada requisição como discutido antes.
  • Gerar um aviso para cada código de status diferente de 200.
  • Parar o laço se o número de requisições atingir o limite máximo.
  • Converter o conteúdo da resposta HTML para um objeto BeautifulSoup.
  • Extrair todos os containers de filmes do objeto BeautifulSoup.
  • Iterar por todos os containers.
  • Extrair os dados se o container possuir um Metascore.
# Redeclaring the lists to store data in
names = []
years = []
imdb_ratings = []
metascores = []
votes = []
# Preparing the monitoring of the loop
start_time = time()
requests = 0
# For every year in the interval 2000-2017
for year_url in years_url:

# For every page in the interval 1-4
for page in pages:

# Make a get request
response = get('http://www.imdb.com/search/title?release_date=' + year_url +
'&sort=num_votes,desc&page=' + page, headers = headers)

# Pause the loop
sleep(randint(8,15))

# Monitor the requests
requests += 1
elapsed_time = time() - start_time
print('Request:{}; Frequency: {} requests/s'.format(requests, requests/elapsed_time))
clear_output(wait = True)

# Throw a warning for non-200 status codes
if response.status_code != 200:
warn('Request: {}; Status code: {}'.format(requests, response.status_code))

# Break the loop if the number of requests is greater than expected
if requests > 72:
warn('Number of requests was greater than expected.')
break

# Parse the content of the request with BeautifulSoup
page_html = BeautifulSoup(response.text, 'html.parser')

# Select all the 50 movie containers from a single page
mv_containers = page_html.find_all('div', class_ = 'lister-item mode-advanced')

# For every movie of these 50
for container in mv_containers:
# If the movie has a Metascore, then:
if container.find('div', class_ = 'ratings-metascore') is not None:

# Scrape the name
name = container.h3.a.text
names.append(name)

# Scrape the year
year = container.h3.find('span', class_ = 'lister-item-year').text
years.append(year)
# Scrape the IMDB rating
imdb = float(container.strong.text)
imdb_ratings.append(imdb)
# Scrape the Metascore
m_score = container.find('span', class_ = 'metascore').text
metascores.append(int(m_score))
# Scrape the number of votes
vote = container.find('span', attrs = {'name':'nv'})['data-value']
votes.append(int(vote))
Request:72; Frequency: 0.07928964663062842 requests/s

Ótimo! Parece que a extração funcionou perfeitamente. O script rodou por aproximadamente 16 minutos.

Agora vamos criar um DataFrame pandas com estes dados e examinar o que conseguimos extrair. Se tudo tiver corrido como esperado, podemos fazer a limpeza dos dados e deixar tudo pronto para a análise.

Examinando os dados extraídos

No próximo trecho de código iremos:

  • Criar um objeto DataFrame do pandas com os dados extraídos.
  • Exibir informações básicas sobre o DataFrame récem-criado.
  • Mostrar as primeiras 10 entradas.
movie_ratings = pd.DataFrame({'movie': names,
'year': years,
'imdb': imdb_ratings,
'metascore': metascores,
'votes': votes})
print(movie_ratings.info())
movie_ratings.head(10)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 2862 entries, 0 to 2861
Data columns (total 5 columns):
imdb 2862 non-null float64
metascore 2862 non-null int64
movie 2862 non-null object
votes 2862 non-null int64
year 2862 non-null object
dtypes: float64(1), int64(2), object(2)
memory usage: 111.9+ KB
None
imdb metascore movie votes year
0 8.5 67 Gladiator 1061075 (2000)
1 8.5 80 Memento 909835 (2000)
2 8.3 55 Snatch 643588 (2000)
3 8.4 68 Requiem for a Dream 617747 (2000)
4 7.4 64 X-Men 485485 (2000)
5 7.7 73 Cast Away 422251 (2000)
6 7.6 64 American Psycho 383669 (2000)
7 7.2 62 Unbreakable 273907 (2000)
8 7.0 73 Meet the Parents 272023 (2000)
9 6.1 59 Mission: Impossible II 256789 (2000)

A saída de info() mostra que coletamos dados sobre mais de 2000 filmes. Podemos ver ainda que não há valor null no nosso dataset.

Verifiquei a avaliações (ratings) dos 10 primeiros filmes no website do IMDB. Todos estão corretos. Você também deve fazer o mesmo.

Podemos prosseguir para a limpeza dos dados.

Limpando os dados extraídos

Vamos limpar os dados extraídos com dois objetivos em mente: plotar a distribuição das avaliações IMDB e do Metascore, e ainda compartilhar o dataset. Portanto, nosso processo de limpeza irá consistir do seguintes passos:

  • Reordenar as colunas.
  • Limpar a coluna yeare converter os valores para inteiro.
  • Verificar valores extremos de avaliação e determinar se todas as avaliações estão dentro do intervalo esperado.
  • Normalizar os tipos de avaliações para gerar um histograma comparativo.

Começaremos reordenando as colunas:

movie_ratings = movie_ratings[['movie', 'year', 'imdb', 'metascore', 'votes']]
movie_ratings.head()
movie year imdb metascore votes
0 Gladiator (2000) 8.5 67 1061075
1 Memento (2000) 8.5 80 909835
2 Snatch (2000) 8.3 55 643588
3 Requiem for a Dream (2000)8.4 68 617747
4 X-Men (2000) 7.4 64 485485

Vamos converter todos os valores da coluna year para inteiros.

Até aqui, todos os valores são do tipo object. Para evitar ValueErrors na conversão, vamos considerar apenas os valores compostos por números de 0 a 9.

Vamos examinar os valores únicos da coluna year. Assim, teremos uma ideia do que podemos fazer para realizar a conversão desejada. Para visualizar os valores únicos, vamos utilizar o método unique():

movie_ratings['year'].unique()array(['(2000)', '(I) (2000)', '(2001)', '(I) (2001)', '(2002)', '(I) (2002)', '(2003)', '(I) (2003)', '(2004)', '(I) (2004)', '(2005)', '(I) (2005)', '(2006)', '(I) (2006)', '(2007)', '(I) (2007)', '(2008)', '(I) (2008)', '(2009)', '(I) (2009)',
'(II) (2009)', '(2010)', '(I) (2010)', '(II) (2010)', '(2011)', '(I) (2011)', '(IV) (2011)', '(2012)', '(I) (2012)', '(II) (2012)', '(2013)', '(I) (2013)', '(II) (2013)', '(2014)', '(I) (2014)',
'(II) (2014)', '(III) (2014)', '(2015)', '(I) (2015)', '(II) (2015)', '(VI) (2015)', '(III) (2015)', '(2016)','(II) (2016)', '(I) (2016)', '(IX) (2016)', '(V) (2016)', '(2017)', '(I) (2017)', '(III) (2017)', '(IV) (2017)'], dtype=object)

Contando do fim para o início, podemos ver que os anos são sempre localizados entre o 5º e o 2º caracteres. Podemos utilizar o métodostr() para selecionar apenas este intervalo. Em seguida converteremos o resultado para inteiro com o método astype():

movie_ratings.loc[:, 'year'] = movie_ratings['year'].str[-5:-1].astype(int)

Vamos visualizar os 3 primeiros valores da coluna year para termos certeza que tudo está correto. Podemos ver o tipo dos valores na ultima linha da saída:

movie_ratings['year'].head(3)0    2000
1 2000
2 2000
Name: year, dtype: int64

Agora vamos verificar os valores máximo e mínimo de cada tipo de avaliação. Podemo ver isto rapidamente com o método describe() do pandas. Quando aplicado a um DataFrame, este método retorna várias estatísticas descritivas para cada coluna numérica do DataFrame. Na próxima linha de código, selecionamos apenas as linhas que descrevem valores mínimos e máximos, e apenas aquelas colunas que descrevem avaliações IMDB e Metascores.

movie_ratings.describe().loc[['min', 'max'], ['imdb', 'metascore']]    imdb metascore 
min 1.6 7.0
max 9.0 100.0

Não há outliers inesperados.

A partir dos valores acima, podemos ver que os dois sistemas de avaliação possuem escalas diferentes. Para poder plotar as duas distribuições em um mesmo gráfico, teremos que trazê-las para a mesma escala. Vamos normalizar a coluna imdb para uma escala de 100 pontos.

Multiplicamos cada avaliação IMDB por 10 e vamos fazer uma verificação rápida nas 3 primeiras colunas:

movie_ratings['n_imdb'] = movie_ratings['imdb'] * 10
movie_ratings.head(3)
movie year imdb metascore votes n_imdb
0 Gladiator 2000 8.5 67 1061075 85.0
1 Memento 2000 8.5 80 909835 85.0
2 Snatch 2000 8.3 55 643588 83.0

Tudo certo! Agora podemos salvar nosso dataset para poder compartilhá-lo mais facilmente. Eu o disponibilizei no meu perfil GitHub. Há outros locais onde se pode compartilhar um dataset, por exemplo Kaggle ou Dataworld.

Então, vamos savá-lo:

movie_ratings.to_csv('movie_ratings.csv')

Eu recomendo fortemente sempre salvar o dataset extraído antes de sair (ou reiniciar) o kernel do notebook. Assim, você só terá que importar o dataset quando você quiser continuar o trabalho não terá que executar todo o script de raspagem de dados novamente. Isto é extremamente útil se você extrair centenas ou milhares de páginas web.

E finalmente, vamos plotar estas distribuições!

Plotando e analizando as distribuições

No código seguinte iremos:

  • Importar o submodulo matplotlib.pyplot.
  • Executar o comando mágico %matplotlibdo Jupyter para ativer o modo matplotlib e adicionar a linha inline se quisermos que nossos gráficos sejam apresentados no próprio notebook.
  • Criar um objeto figure com 3 axes.
  • Plotar a distribuição de cada avaliação não normalizada em um ax individual.
  • Plotar a distribuição normalizada das duas avaliações em um mesmo ax.
  • Esconder a linha superior e à direito na área do gráfico nos três axes.
import matplotlib.pyplot as plt
%matplotlib inline
fig, axes = plt.subplots(nrows = 1, ncols = 3, figsize = (16,4))
ax1, ax2, ax3 = fig.axes
ax1.hist(movie_ratings['imdb'], bins = 10, range = (0,10)) # bin range = 1
ax1.set_title('IMDB rating')
ax2.hist(movie_ratings['metascore'], bins = 10, range = (0,100)) # bin range = 10
ax2.set_title('Metascore')
ax3.hist(movie_ratings['n_imdb'], bins = 10, range = (0,100), histtype = 'step')
ax3.hist(movie_ratings['metascore'], bins = 10, range = (0,100), histtype = 'step')
ax3.legend(loc = 'upper left')
ax3.set_title('The Two Normalized Distributions')
for ax in fig.axes:
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
plt.show()

Começando com o histograma do IMDB, podemos observar que a maioria das avaliações está entre 6 e 8. Há poucos filmes com uma avaliação maior que 8 e menos ainda com uma avaliação menor que 4. Isto indica que tanto filmes muito bons quanto muito ruins são raros.

A distribuição da avaliação Metascore parece seguir uma distribuição normal — a maioria das avaliações está na média, com pico no valor próximo de 50. A partir do pico, as frequências gradualmente decrescem em direção aos valores extremos de avaliação. De acordo com esta distribuição, há realmente poucos filmes muito bons e muito ruins, mas não poucos quando indicado pelas avaliações do IMDB.

No gráfico comparativo, fica claro que a distribuição IMDB tende ligeiramente para parte superior das avaliações, enquanto que as avaliações do Metascore parecem ter uma distribuição muito mais balanceada.

Qual pode ser a razão para uma distribuição tão desequilibrada? Uma hipótese é que muitos usuários tendem a ter um método binário de avaliação de filmes. Se eles gostam de um filme eles dão 10. Se eles não gostam do filme, eles dão uma avaliação bem baixa, ou nem se importam de dar avaliação alguma. Este é um problema interessante e que vale a pena ser investigado em mais detalhes.

Próximos passos

Progredimos muito desde a requisição do conteúdo de uma página simples até sermos capazes de analisar a avaliação de mais de 2000 filmes. Você deve está apto agora a extrair multiplas páginas web que possuam uma mesma estrutura de HTML e URL.

Para exercitar o que você acabou de aprender, sugiro aqui algumas coisas que você pode tentar:

  • Extraia dados para períodos de tempo e intervalos de página distintos.
  • Extraia mais dados sobre os filmes.
  • Encontre um website e tente extrair informações do seu interesse. Por exemplo, você pode extrair dados sobre laptops para ver a variação de preços ao longo do tempo.

Nota do tradutor: Post original em inglês por Alexandru Olteanu no dataquest.io (link)

--

--