Curso

Puedes encontrar un ejemplo final y funcional del script que vamos a escribir aquí.
¿Qué es el web scraping?
Bien, ¿en qué consiste exactamente el web scraping? Como su nombre sugiere, es un método para "raspar" o extraer datos de páginas web. Casi todo lo que ves en internet con tu navegador —incluido este tutorial— puede descargarse a tu disco duro.
El web scraping tiene muchísimos usos. En cualquier análisis de datos, el primer paso es la adquisición de datos. Internet es un enorme repositorio de la historia y el conocimiento de la humanidad, y tienes a tu alcance extraer lo que necesites y trabajar con esa información.
En este tutorial usaremos Python y el paquete BeautifulSoup 4 para obtener información de un subreddit. Nos interesa el subreddit datascience. Queremos obtener los primeros 1000 posts del subreddit y exportarlos a un archivo CSV. Nos interesa saber quién lo publicó, cuántos votos positivos tiene y cuántos comentarios.
Qué vamos a cubrir en el tutorial:
- Obtener páginas web con
requests - Analizar páginas web en el navegador para identificar información
- Extraer información del HTML con BeautifulSoup
Nota: usaremos la versión antigua de la web de Reddit porque carga más rápido y, por tanto, es menos exigente para tu equipo.
Requisitos previos
Este tutorial asume que sabes lo siguiente:
- Ejecutar scripts de Python en tu ordenador
- Conocer la estructura básica de HTML
Puedes aprender estas habilidades en el curso para principiantes de Python de DataCamp. Aun así, los conceptos que usamos aquí son mínimos, y podrás seguirlo aunque tengas muy poca experiencia con Python.
Con esto listo, pasamos a la primera parte de nuestro scraper. De hecho, el primer paso al escribir cualquier script en Python: los imports.
En nuestro scraper usaremos los siguientes paquetes:
requestsbeautifulsoup4
Puedes instalarlos con pip, así de simple:
pip install package_name
Cuando termines de descargarlos, impórtalos en tu código.
import requests
import csv
import time
from bs4 import BeautifulSoup4
Usaremos el módulo integrado csv de Python para escribir los resultados en un archivo CSV. Quizá te haya llamado la atención algo en el fragmento anterior: hemos descargado un paquete llamado beautifulsoup4, pero importamos desde un módulo llamado bs4. Esto es válido en Python; aunque suele desaconsejarse por estilo, no es "ilegal".
Nota importante sobre las políticas de la plataforma
Antes de usar las técnicas de scraping que verás a continuación, asegúrate de revisar los términos de uso vigentes de Reddit o de cualquier otro sitio que planees scrapear. Las políticas pueden cambiar con el tiempo. El acuerdo de usuario de Reddit de septiembre de 2024 prohíbe el scraping sin consentimiento previo por escrito (ver sección 7). Verifica siempre que cumples con las directrices de uso más recientes del sitio antes de continuar.
Primeros pasos para rastrear Reddit
Ya tenemos el entorno listo. Ahora necesitamos la URL de la página que queremos scrapear. Para este tutorial, usaremos el subreddit "datascience" de Reddit. Antes de escribir el script, toca un poco de trabajo de campo: abre un navegador y ve al subreddit en cuestión.
Usaremos la versión antigua del subreddit. La nueva oculta parte de la información clave en las tripas de la página. También es posible extraerla de la nueva versión, pero para simplificar, usaremos la versión antigua, que lo muestra todo de forma más directa.
Al abrir el enlace, te encontrarás con un montón de información. ¿Qué necesitamos exactamente? Si exploras los enlaces, verás que los posts de Reddit son de dos tipos:
- Inbound
- Outbound
Los enlaces inbound apuntan a contenido dentro del propio Reddit; los outbound, justo a lo contrario. Esto es importante porque solo queremos los inbound: publicaciones con texto escrito por usuarios, no solo enlaces a otras webs.
Bien, ya sabemos qué queremos. ¿Cómo lo identificamos? Si miras el título de los posts, verás que va seguido de un texto entre paréntesis. Los que nos interesan vienen seguidos de "(self.datascience)". Lógicamente, podemos asumir que "self" se refiere a la raíz de Reddit y ".datascience" al subreddit.
Genial, ya tenemos una forma de distinguir posts inbound y outbound. Ahora toca identificarlo en el DOM. ¿Podemos localizar estos identificadores buscando etiquetas, clases o ids en la estructura del DOM? ¡Claro que sí!
En tu navegador, haz clic derecho sobre cualquier enlace con "self.datascience" y selecciona "inspeccionar elemento". La mayoría de navegadores modernos incluyen esta fantástica herramienta que permite recorrer dinámicamente las partes relevantes del código fuente de la página. Si usas Safari, activa las herramientas para desarrolladores en sus preferencias. Verás un panel desplazado a la parte del código responsable de ese identificador "(self.datascience)".
En el panel, verás que el identificador está dentro de una etiqueta de enlace (anchor).
<a href="/r/datascience">self.datascience</a>
Pero todo ello va envuelto en un span.
<span class="domain">
"("
<a href="/r/datascience">self.datascience</a>
")"
</span>
Este span contiene el texto que vemos en la página, es decir, "(self.datascience)". Como puedes ver, tiene la clase "domain". Si revisas otros enlaces, verás que siguen el mismo formato.
Obtener la página con BeautifulSoup
Ya sabemos qué queremos de la página, pero ¿cómo hacemos que Python lea su contenido? Funciona de forma muy parecida a como lo haría una persona en el navegador.
Primero, debemos solicitar la página web con la librería requests.
url = "https://old.reddit.com/r/datascience"
# Cabeceras para imitar una visita del navegador
headers = {'User-Agent': 'Mozilla/5.0'}
# Devuelve un objeto requests.models.Response
page = requests.get(url, headers=headers)
Ahora tenemos un objeto Response que contiene el texto bruto de la página. De momento no podemos hacer gran cosa con él: básicamente es una cadena enorme con todo el HTML. Para interpretarlo, necesitamos BeautifulSoup 4.
Las cabeceras nos permiten imitar una visita de navegador. Como la respuesta a un bot puede diferir de la que recibe un navegador, y nuestro punto de referencia es el navegador, nos interesa obtener esa respuesta.
BeautifulSoup nos permite buscar etiquetas concretas combinando clases, ids o nombres de etiqueta. Lo hace creando un árbol de sintaxis, pero esos detalles no nos hacen falta ahora.
Vamos a crear ese árbol.
soup = BeautifulSoup(page.text, 'html.parser')
"soup" es un objeto de BeautifulSoup creado a partir del código fuente en bruto. Ten en cuenta que debemos especificar el parser de HTML, ya que BeautifulSoup también puede parsear XML.
Encontrar nuestras etiquetas
Ya sabemos qué etiquetas queremos (spans con la clase "domain") y ya tenemos el soup. Lo siguiente es recorrer el soup y encontrar todas las instancias. Te sorprenderá lo sencillo que es con BeautifulSoup.
domains = soup.find_all("span", class_="domain")
¿Qué acabamos de hacer? Llamamos al método find_all sobre el objeto soup, que busca dentro de sí todas las etiquetas con los parámetros proporcionados. Solo pasamos una restricción (la clase debe ser "domain"), pero podríamos combinarla con otras o usar un id.
Usamos "class_=" porque "class" es una palabra reservada en Python para definir clases, etc.
Si quisieras pasar más de un parámetro, basta con convertir el segundo argumento en un diccionario con los atributos deseados, así:
soup.find_all("span", {"class": "domain", "height", "100px"})
Ya tenemos todos los span en nuestra lista "domains", pero lo que queremos son los que muestran "(self.datascience)".
for domain in domains:
if domain != "(self.datascience)":
continue
print(domain.text)
Ahora deberías ver un listado de los tipos de posts de la página, excluyendo los que no son "(self.datascience)". Con esto tenemos referencias a todos los posts inbound.
Encontrar nuestra información
Está bien imprimir unas cuantas líneas de "(self.datascience)", pero ¿y ahora qué? Recuerda nuestro objetivo: obtener el título del post, el autor, los likes y el número de comentarios.
Para ello volvemos al navegador. Si vuelves a inspeccionar nuestro identificador, verás que el span está anidado dentro de un div… que a su vez está dentro de otro div, y así sucesivamente. Si sigues subiendo, llegarás al div padre de todo el post.
<div class=" thing id-t3_8qccuv even link self" ...>
...
<div class="entry unvoted">
<div class="top-matter">
<p class="title">
...
<span class="domain">
...
</p>
</div>
</div>
</div>
Como ves, el padre común está 4 niveles por encima en el DOM. Los puntos suspensivos (…) representan contenido irrelevante. Ahora necesitamos una referencia al div del post para cada elemento de nuestra lista de domains. Podemos encontrar el padre de cualquier elemento en nuestro soup usando los métodos de BeautifulSoup.
for domain in soup.find_all("span", class_="domain"):
if domain != "(self.datascience)":
continue
parent_div = domain.parent.parent.parent.parent
print(parent_div.text)
Al ejecutar el script, se imprimirá el texto de todos los posts inbound. Puede que pienses que este código es frágil, y tienes razón. Nunca es buena idea depender solo de la estructura del DOM: es una solución propensa a romperse con cualquier cambio.
En su lugar, echemos un vistazo al div padre de cada post para ver si podemos distinguir inbound y outbound desde ahí. Cada div padre tiene un atributo llamado "data-domain", cuyo valor es justo lo que buscamos. Todos los posts inbound tienen "data-domain" igual a "self.datascience".
Como comentamos antes, podemos buscar etiquetas con una combinación de atributos usando BeautifulSoup. Y por suerte, Reddit clasifica el div padre de cada post con la clase "thing".
attrs = {'class': 'thing', 'data-domain': 'self.datascience'}
for post in soup.find_all('div', attrs=attrs):
print(post.attrs['data-domain'])
La variable "attrs" es un diccionario con los atributos y valores que queremos buscar. Este enfoque es más seguro porque reduce las piezas móviles. Como ya filtramos por "self.datascience" al buscar, no necesitamos usar un if para saltarnos iteraciones: solo recibiremos posts con ese atributo.
Ahora que ya tenemos localizados los posts, solo nos falta extraer la información de sus elementos hijos. Y sí, es tan sencillo como parece.
Extraer la información de Reddit
De cada post necesitamos 4 datos:
- Título
- Autor
- Likes
- Comentarios
Si revisamos la estructura del div del post, podemos encontrarlo todo.
El título
Este es el más sencillo. En cada div del post hay un párrafo anidado a través de un par de divs. Es fácil de encontrar porque tiene la clase "title".
title = post.find('p', class_="title").text
El objeto "post" está dentro de nuestro bucle for anterior. También es un objeto de BeautifulSoup, pero contiene solo la estructura del DOM dentro del div del post. El método "find" devuelve un único objeto (a diferencia de "find_all", que devuelve una lista). Después de encontrar la etiqueta, queremos la cadena con el título, así que usamos su propiedad "text". También podemos leer otros atributos con "objeto.attrs['atributo']".
El autor
También es sencillo: inspecciona el nombre del autor bajo el título. Verás que está en una etiqueta de enlace con la clase "author".
author = post.find('a', class_='author').text
Los comentarios
Aquí hay un pequeño paso extra, pero sigue siendo fácil. Podemos encontrar los comentarios igual que el título y el autor.
comments = post.find('a', class_='comments').text
Al ejecutar esto obtendrás algo como "49 comments". Está bien, pero mejor quedarnos solo con el número. Para ello tiramos de un poco de Python.
Si usamos str.split() nos devolverá una lista con los elementos de la cadena separados por espacios. En este caso, obtendremos ["49", "comments"]. ¡Perfecto! Solo tenemos que quedarnos con el primer elemento.
comments = post.find('a', class_='comments').text.split()[0]
Aún falta un detalle: a veces no obtenemos un número, sino "comment". Pasa cuando no hay comentarios en un post. Como lo sabemos, comprobamos si el resultado es "comment" y lo sustituimos por "0".
if comments == "comment":
comments = 0
Los likes
Encontrar el número de likes es pan comido y sigue la misma lógica.
likes = post.find("div", attrs={"class": "score likes"}).text
Fíjate en que hemos usado una combinación de dos clases. Es porque hay otros divs con la clase "score" o "likes", pero solo uno con la combinación "score likes".
Si el número de likes es 0, obtenemos 0. Pero si el post es tan reciente que aún no tiene votos, aparece "•". Vamos a sustituirlo por "None" para evitar confusiones en los resultados.
if likes == "•":
likes = "None"
Escribir los resultados en CSV
Hasta ahora tenemos un bucle que extrae título, autor, likes y comentarios para cada post de la página. Python incluye un módulo estupendo para leer y escribir CSV llamado "csv". Solo tenemos que añadir una línea al final del bloque del bucle para añadir los datos al archivo CSV.
counter = 1
for post in posts:
...
post_line = [counter, title, author, likes, comments]
with open('output.csv', 'a') as f:
writer = csv.writer(f)
writer.writerow(post_line)
counter += 1
Directo y claro, ¿verdad? "post_line" es un array con los elementos que irán separados por comas. La variable "counter" sirve para llevar la cuenta de cuántos posts hemos registrado.
Pasar a la siguiente página
Como contamos cuántos posts guardamos, basta con encerrar toda la lógica en otro bucle que solicite nuevas páginas hasta alcanzar cierta cantidad de posts.
counter = 1
while (counter <= 100):
for post in posts:
# Obtener título, autor, ...
# Escribir en CSV e incrementar counter...
next_button = soup.find("span", class_="next-button")
next_page_link = next_button.find("a").attrs['href']
time.sleep(2)
page = requests.get(next_page_link, headers=headers)
soup = BeautifulSoup(page.text, 'html.parser')
Hemos hecho varias cosas. Primero, movimos la variable "counter" fuera del bucle for para que pueda usarla el while.
Luego, ya sabemos qué hace el for, pero ¿y el resto? "next_button" localiza y guarda el botón "next". Después, buscamos el enlace dentro y obtenemos el atributo "href", que guardamos en "next_page_link".
Con ese enlace solicitamos la siguiente página, la guardamos en "page" y generamos de nuevo el soup con BeautifulSoup.
El fragmento anterior se detiene en 100, pero puedes fijar el límite que quieras.
Scrapear con responsabilidad
La línea de la que no hemos hablado es "time.sleep(2)". Merece su propia sección. Cualquier servidor web tiene recursos limitados; depende de nosotros no acapararlos. Además de que pueden banearte si haces cientos de peticiones en segundos, tampoco es correcto.
Recuerda que es un detalle por parte del sitio permitirte scrapear: podrían detectar bots tras 10 o 20 peticiones, o identificarte por el objeto de la solicitud que envía Python. Si te permiten scrapear sin necesidad de rotar IP, compórtate y ralentiza tu bot.
Puedes consultar la política de rastreo de un sitio en su archivo robots.txt. Suele estar en la raíz (p. ej., http://www.reddit.com/robots.txt).
¿Y ahora qué?
Lo que quieras. Como acabamos de ver, todo lo que ves en la web puede scrapearse y almacenarse localmente. Puedes aprovechar la información que hemos obtenido para múltiples fines. Con solo estos cuatro datos ya podemos sacar muchas conclusiones.
Analizando más a fondo, puedes descubrir qué tipo de posts de Reddit reciben más likes, qué palabras contienen, quién los publica. O, a la inversa, construir un "calculador de controversia" analizando la proporción de likes y comentarios.
Piensa en un post que recibe muchos comentarios pero pocos likes. Probablemente toca un tema que hace reaccionar a la gente, pero no necesariamente les gusta.
Las posibilidades son enormes, y está en tu mano decidir cómo usar la información.
Si te ha gustado este tutorial, echa un vistazo a nuestra guía para scrapear Amazon con Python y a nuestra colección de cursos de Python.
