Web Scraping con Python y BeautifulSoup

web scraping python beautifulsoup matplotlib panda

El web scraping es una buena habilidad para diferentes usos más allá de que se sea un científico de datos o no. Mucha información que reside en la web es útil para ser analizada y elaborada. Este post sobre web scraping con Python, está dividido en tres partes.

• Extracción de datos de la web usando el módulo Beautiful Soup

• Manipulación de datos y limpieza usando la librería Panda

• Visualización de datos mediante Matplotlib

El dataset usado en este tutorial fue tomado de una carrera que tuvo lugar en Hillsboro, OR en Junio de 2017. Se podrá responder entonces preguntas como:

• Cuál fue el tiempo promedio de los corredores?

• Los tiempos de llegada responden a una distribución normal?

• Hubo diferencia de tiempos entre mujeres y hombres de diferentes grupos de edades?

Web Scraping con BeautifulSoup

Usando Jupyter Notebook, se deben importar las librerías necesarias (pandas, numpy, matplotlib.pyplot, seaborn). Si no lo tienes instalado, se recomienda utilizar Anaconda con ese fin. Para visualizar los gráficos, asegurarse de incluir la línea %matplotlib inline como se muestra a continuación.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
%matplotlib inline

Las librerías especificas de web scraping son urllib.request, que es usado para abrir URLs y el paquete BeautifulSoup que es usado para extraer los datos de los html.

from urllib.request import urlopen
from bs4 import BeautifulSoup

Seguido, hay que especificar la URL que contiene el dataset en urlopen() para obtener el html de la página.

url = "http://www.hubertiming.com/results/2017GPTR10K"
html = urlopen(url)

El primer paso entonces será obtener el hatml de la página, seguido, crear un objeto BeautifulSoup pasando el  html a BeautifulSoup(). Este paquete es usado para parsear el html crudo en objetos de Python. El segundo argumento ‘lxml’ lo veremos después.

soup = BeautifulSoup(html, 'lxml')
type(soup)

bs4.BeautifulSoup

El objeto soup permite extraer información interesante sobre el website como el título tal como se muestra aquí.

# Get the title
title = soup.title
print(title)

<title>2017 Intel Great Place to Run 10K \ Urban Clash Games Race Results</title>

También se puede obtener el texto e imprimirlo para verificar que sea lo esperado.

# Print out the text
text = soup.get_text()
#print(soup.text)

Se puede ver el html de una página web haciendo right-clicking en cualquier lugar y seleccionando “Inspect.”

Usando el método find_all() de soup se pueden extraer todos los tags de html dentro de una página web. Ejemplos útiles de tags son  < a > para hyperlinks, < table > para tables, < tr > para table rows, < th > para table headers, y < td > para table cells.

soup.find_all('a')

[<a class="btn btn-primary btn-lg" href="/results/2017GPTR" role="button">5K</a>,
 <a href="http://hubertiming.com/">Huber Timing Home</a>,
 <a href="#individual">Individual Results</a>,
 <a href="#team">Team Results</a>,
 <a href="mailto:[email protected]">[email protected]</a>,
 <a href="#tabs-1" style="font-size: 18px">Results</a>,
 <a name="individual"></a>,
 <a name="team"></a>,
 <a href="http://www.hubertiming.com/"><img height="65" src="/sites/all/themes/hubertiming/images/clockWithFinishSign_small.png" width="50"/>Huber Timing</a>,
 <a href="http://facebook.com/hubertiming/"><img src="/results/FB-f-Logo__blue_50.png"/></a>]

Como se puede ver en la salida anterior, los tags de html vienen a veces con atributos scomo class, src, etc. que proveen información adicional sobre los elementos. Se puede usar un loop y el método get(‘”href”) para extraer solo hyperlinks.

all_links = soup.find_all("a")
for link in all_links:
    print(link.get("href"))

/results/2017GPTR
http://hubertiming.com/
#individual
#team
mailto:[email protected]
#tabs-1
None
None
http://www.hubertiming.com/
http://facebook.com/hubertiming/

Para imprimir solo filas de tablas, hay que pasar a soup.find_all() el argumento ‘tr’.

# Print the first 10 rows for sanity check
rows = soup.find_all('tr')
print(rows[:10])

[<tr><td>Finishers:</td><td>577</td></tr>, <tr><td>Male:</td><td>414</td></tr>, <tr><td>Female:</td><td>163</td></tr>, <tr class="header">
<th>Place</th>
<th>Bib</th>
<th>Name</th>
<th>Gender</th>
<th>City</th>
<th>State</th>
<th>Chip Time</th>
<th>Chip Pace</th>
<th>Gender Place</th>
<th>Age Group</th>
<th>Age Group Place</th>
<th>Time to Start</th>
<th>Gun Time</th>
<th>Team</th>
</tr>, <tr>
<td>1</td>
<td>814</td>
<td>JARED WILSON</td>
<td>M</td>
<td>TIGARD</td>
<td>OR</td>
<td>00:36:21</td>
<td>05:51</td>
<td>1 of 414</td>
<td>M 36-45</td>
<td>1 of 152</td>
<td>00:00:03</td>
<td>00:36:24</td>
<td></td>
</tr>, <tr>
<td>2</td>
<td>573</td>
<td>NATHAN A SUSTERSIC</td>
<td>M</td>
<td>PORTLAND</td>
<td>OR</td>
<td>00:36:42</td>
<td>05:55</td>
<td>2 of 414</td>
<td>M 26-35</td>
<td>1 of 154</td>
<td>00:00:03</td>
<td>00:36:45</td>
<td>INTEL TEAM F</td>
</tr>, <tr>
<td>3</td>
<td>687</td>
<td>FRANCISCO MAYA</td>
<td>M</td>
<td>PORTLAND</td>
<td>OR</td>
<td>00:37:44</td>
<td>06:05</td>
<td>3 of 414</td>
<td>M 46-55</td>
<td>1 of 64</td>
<td>00:00:04</td>
<td>00:37:48</td>
<td></td>
</tr>, <tr>
<td>4</td>
<td>623</td>
<td>PAUL MORROW</td>
<td>M</td>
<td>BEAVERTON</td>
<td>OR</td>
<td>00:38:34</td>
<td>06:13</td>
<td>4 of 414</td>
<td>M 36-45</td>
<td>2 of 152</td>
<td>00:00:03</td>
<td>00:38:37</td>
<td></td>
</tr>, <tr>
<td>5</td>
<td>569</td>
<td>DEREK G OSBORNE</td>
<td>M</td>
<td>HILLSBORO</td>
<td>OR</td>
<td>00:39:21</td>
<td>06:20</td>
<td>5 of 414</td>
<td>M 26-35</td>
<td>2 of 154</td>
<td>00:00:03</td>
<td>00:39:24</td>
<td>INTEL TEAM F</td>
</tr>, <tr>
<td>6</td>
<td>642</td>
<td>JONATHON TRAN</td>
<td>M</td>
<td>PORTLAND</td>
<td>OR</td>
<td>00:39:49</td>
<td>06:25</td>
<td>6 of 414</td>
<td>M 18-25</td>
<td>1 of 34</td>
<td>00:00:06</td>
<td>00:39:55</td>
<td></td>
</tr>]

El objetivo es tomar de la tabla del sitio y convertirlo en un dataframe para utilizar la información más fácilmente con Python. Para eso usaremos un loop que itera a través de las filas e imprime las celdas en las filas.

for row in rows:
    row_td = row.find_all('td')
print(row_td)
type(row_td)

[<td>14TH</td>, <td>INTEL TEAM M</td>, <td>04:43:23</td>, <td>00:58:59 - DANIELLE CASILLAS</td>, <td>01:02:06 - RAMYA MERUVA</td>, <td>01:17:06 - PALLAVI J SHINDE</td>, <td>01:25:11 - NALINI MURARI</td>]


bs4.element.ResultSet

La salida muestra que se imprimen las filas con los tags de HTML, cosa que no queremos, pero podemos usar el remove de HTML del soup, get_text.

str_cells = str(row_td)
cleantext = BeautifulSoup(str_cells, "lxml").get_text()
print(cleantext)

[14TH, INTEL TEAM M, 04:43:23, 00:58:59 - DANIELLE CASILLAS, 01:02:06 - RAMYA MERUVA, 01:17:06 - PALLAVI J SHINDE, 01:25:11 - NALINI MURARI]

Using regular expressions is highly discouraged since it requires several lines of code and one can easily make mistakes. It requires importing the re (for regular expressions) module. The code below shows how to build a regular expression that finds all the characters inside the < td > html tags and replace them with an empty string for each table row.

First, you compile a regular expression by passing a string to match to re.compile(). The dot, star, and question mark (.*?) will match an opening angle bracket followed by anything and followed by a closing angle bracket. It matches text in a non-greedy fashion, that is, it matches the shortest possible string. If you omit the question mark, it will match all the text between the first opening angle bracket and the last closing angle bracket. After compiling a regular expression, you can use the re.sub() method to find all the substrings where the regular expression matches and replace them with an empty string. The full code below generates an empty list, extract text in between html tags for each row, and append it to the assigned list.

import re

list_rows = []
for row in rows:
    cells = row.find_all('td')
    str_cells = str(cells)
    clean = re.compile('<.*?>')
    clean2 = (re.sub(clean, '',str_cells))
    list_rows.append(clean2)
print(clean2)
type(clean2)

[14TH, INTEL TEAM M, 04:43:23, 00:58:59 - DANIELLE CASILLAS, 01:02:06 - RAMYA MERUVA, 01:17:06 - PALLAVI J SHINDE, 01:25:11 - NALINI MURARI]

str

The next step is to convert the list into a dataframe and get a quick view of the first 10 rows using Pandas.

df = pd.DataFrame(list_rows)
df.head(10)
0
0 [Finishers:, 577]
1 [Male:, 414]
2 [Female:, 163]
3 []
4 [1, 814, JARED WILSON, M, TIGARD, OR, 00:36:21…
5 [2, 573, NATHAN A SUSTERSIC, M, PORTLAND, OR, …
6 [3, 687, FRANCISCO MAYA, M, PORTLAND, OR, 00:3…
7 [4, 623, PAUL MORROW, M, BEAVERTON, OR, 00:38:…
8 [5, 569, DEREK G OSBORNE, M, HILLSBORO, OR, 00…
9 [6, 642, JONATHON TRAN, M, PORTLAND, OR, 00:39…

Manipulación y Limpieza de Datos

El dataframe no está en el formato que pretendemos, para limpiarlo, debemos dividir la columna 0 en varias en la posición de la coma usando el método str.split().

df1 = df[0].str.split(',', expand=True)
df1.head(10)

El dataframe tiene corchetes no deseados en cada fila, se puede usar el método strip() para eliminarlos en la columna “0.”

df1[0] = df1[0].str.strip('[')
df1.head(10)

La tabla no tiene encabezados, se puede usar el método find_all() para armarlo

col_labels = soup.find_all('th')

Como con las filas de tabla, se puede usar Beautiful Soup para extraer los textos de los tags del HTML para los headers.

all_header = []
col_str = str(col_labels)
cleantext2 = BeautifulSoup(col_str, "lxml").get_text()
all_header.append(cleantext2)
print(all_header)

['[Place, Bib, Name, Gender, City, State, Chip Time, Chip Pace, Gender Place, Age Group, Age Group Place, Time to Start, Gun Time, Team]']

Convertir lista de encabezados en un dataframe de pandas.

df2 = pd.DataFrame(all_header)
df2.head()
0
0 [Place, Bib, Name, Gender, City, State, Chip T…

De igual forma, se puede separar la comuna 0 en múltiples columnas en la posición de la coma para todas las filas

df3 = df2[0].str.split(',', expand=True)
df3.head()

Los dos dataframes pueden ser concatenados en uno el método concat()

frames = [df3, df1]

df4 = pd.concat(frames)
df4.head(10)

Cómo asignar la primera fila a la cabecera de la tabla.

df5 = df4.rename(columns=df4.iloc[0])
df5.head()

scraping dataset matplotlib

Ya en estado formateado, se puede sacar un overview de los datos para evaluar

df5.info()
df5.shape

<class 'pandas.core.frame.DataFrame'>
Int64Index: 597 entries, 0 to 595
Data columns (total 14 columns):
[Place              597 non-null object
 Bib                596 non-null object
 Name               593 non-null object
 Gender             593 non-null object
 City               593 non-null object
 State              593 non-null object
 Chip Time          593 non-null object
 Chip Pace          578 non-null object
 Gender Place       578 non-null object
 Age Group          578 non-null object
 Age Group Place    578 non-null object
 Time to Start      578 non-null object
 Gun Time           578 non-null object
 Team]              578 non-null object
dtypes: object(14)
memory usage: 70.0+ KB





(597, 14)

La tabla tiene 597 filas y 14 columnas. Se pueden eliminar todas las de valores perdidos.

df6 = df5.dropna(axis=0, how='any')

La cabecera es replicada como la primera fila en  df5.

df7 = df6.drop(df6.index[0])
df7.head()

panda escrapear python

Se puede hacer más limpieza renombrando las columnas ‘[Place’ and ‘ Team]’. Python es muy sensible con los espacios, asegúrese de incluir espacio después de las tildes en ‘ Team]’.

df7.rename(columns={'[Place': 'Place'},inplace=True)
df7.rename(columns={' Team]': 'Team'},inplace=True)
df7.head()

libreria beautiful soap modulo

Ultimo paso, remover los corchetes en celdas en la columna “Team”.

df7['Team'] = df7['Team'].str.strip(']')
df7.head()

webscraping panda beautiful soap

Listo!

Análisis y Visualización de Datos

La primera pregunta es el promedio de tiempos de llegada.

Hay que convertir la comuna “Chip Time” en minutos. Una manera es convertir la columna en una lista para manipularla

time_list = df7[' Chip Time'].tolist()

# You can use a for loop to convert 'Chip Time' to minutes

time_mins = []
for i in time_list:
    h, m, s = i.split(':')
    math = (int(h) * 3600 + int(m) * 60 + int(s))/60
    time_mins.append(math)
#print(time_mins)

El siguiente paso es convertir otra vez la lista en un dataframe y hacer una columna (“Runner_mins”) para los tiempos de llegada expresados en minutos.

df7['Runner_mins'] = time_mins
df7.head()

manipulacion y limpieza de datos panda python

Cómo calcular estadísticas para columnas numéricas en un dataframe.

df7.describe(include=[np.number])
Runner_mins
count 577.000000
mean 60.035933
std 11.970623
min 36.350000
25% 51.000000
50% 59.016667
75% 67.266667
max 101.300000

Interesantemente los tiempos de llegada son de aproximadamente 60 min. El más rápido terminó en  36.35 mins, y el más lento en 101.30 minutos.

El boxplot es otra herramienta util para visualizar datos estadísticos (maximum, minimum, medium, first quartile, third quartile, incluyendo outliers).

from pylab import rcParams
rcParams['figure.figsize'] = 15, 5

df7.boxplot(column='Runner_mins')
plt.grid(True, axis='y')
plt.ylabel('Chip Time')
plt.xticks([1], ['Runners'])

([<matplotlib.axis.XTick at 0x570dd106d8>],
 <a list of 1 Text xticklabel objects>)

como escrapear una web con python

La segunda pregunta es si los tiempos de llegada forman una distribución normal

En este caso se usa la librería Seaborn donde se ve que la distribución parece casi normal.

x = df7['Runner_mins']
ax = sns.distplot(x, hist=True, kde=True, rug=False, color='m', bins=25, hist_kws={'edgecolor':'black'})
plt.show()

web scrapper python beautifulsoap

La tercera pregunta es cuando hay diferencia entre hombres y mujeres en diferentes grupos de edades.

f_fuko = df7.loc[df7[' Gender']==' F']['Runner_mins']
m_fuko = df7.loc[df7[' Gender']==' M']['Runner_mins']
sns.distplot(f_fuko, hist=True, kde=True, rug=False, hist_kws={'edgecolor':'black'}, label='Female')
sns.distplot(m_fuko, hist=False, kde=True, rug=False, hist_kws={'edgecolor':'black'}, label='Male')
plt.legend()

<matplotlib.legend.Legend at 0x570e301fd0>

distribución normal python web scraper

La distribución indica que las mukeres fueron más lentas en promedio. Se puede usar el método groupby() para calcular los totales para los hombres y mujeres por separado.

g_stats = df7.groupby(" Gender", as_index=True).describe()
print(g_stats)

        Runner_mins                                                         \
              count       mean        std        min        25%        50%   
 Gender                                                                      
 F            163.0  66.119223  12.184440  43.766667  58.758333  64.616667   
 M            414.0  57.640821  11.011857  36.350000  49.395833  55.791667   


               75%         max  
 Gender                         
 F       72.058333  101.300000  
 M       64.804167   98.516667  

El promedio de tiempo para todas las mujeres y hombres fue ~66
mins y ~58 mins, respectivamente.

df7.boxplot(column='Runner_mins', by=' Gender')
plt.ylabel('Chip Time')
plt.suptitle("")

C:\Users\smasango\AppData\Local\Continuum\anaconda3\lib\site-packages\numpy\core\fromnumeric.py:57: FutureWarning: reshape is deprecated and will raise in a subsequent release. Please use .values.reshape(...) instead
  return getattr(obj, method)(*args, **kwds)


Text(0.5,0.98,'')

visualizacion de datos matplotlib python

fuente: datacamp

Check Also

dot dot dot … tres puntos en GO (Golang), Funciones Variádicas

Para quienes vienen de otros lenguajes que no poseen funciones variádicas, esto es algo "diferente". Los ... tres puntos de go (golang) es de gran utilidad.

Leave a Reply

Your email address will not be published. Required fields are marked *