Hacer scrape es el proceso de extraer datos de sitios web.

Antes de realizar la extracción de datos de una página web, debes asegurarte de que el proveedor lo permita en sus términos de servicio. Además, deberías verificar si no puedes usar una API en su lugar.

Una extracción masiva de datos puede poner al servidor bajo un enorme esfuerzo, lo cual puede resultar en una denegación de servicio. Y no quieres eso.

¿Quién debería leer esto?

Este artículo es para lectores avanzados. Se asume que ya estás familiarizado con el lenguaje de programación de Python.

Como mínimo, debes entender la comprensión de listas, el administrador de contexto y las funciones. También debes saber cómo configurar un entorno virtual.

Ejecutaremos el código en tu máquina local para explorar algunos sitios web.

Qué aprenderás en este artículo

Al final de este artículo, sabrás cómo descargar una página web, analizarla en busca de información interesante y darle un formato utilizable para su posterior procesamiento. Esto también se conoce como ETL.

Este artículo también explicará qué hacer si ese sitio web usa JavaScript para representar (render) contenido (como React.js o Angular).

Pre-requisitos

Antes de comenzar, deseo asegurarme de que estemos listo para dar inicio. Por favor, establece un entorno virtual e instálale los siguientes paquetes:

  • beautifulsoup4 (versión 4.9.0 al momento de estar escribiendo el artículo)
  • requests (versión 2.23.0 al momento de estar escribiendo el artículo)
  • wordcloud (versión 1.17.0 al momento de estar escribiendo el artículo, opcional)
  • selenium (versión 3.141.0 al momento de estar escribiendo el artículo, opcional)

Puedes encontrar el código de este proyecto en este repositorio git en GitHub

Para este ejemplo, realizaremos una extracción (scrape) de la Ley Básica para la República Federal de Alemania. (No te preocupes, ya verifiqué los Términos de Servicios. Ofrecen una versión XML para procesamiento de máquina, pero esta página sirve como un ejemplo de procesamiento de HTML. Entonces debería estar bien.)

Paso 1: Descargar la fuente

Primero lo primero: creé un archivo urls.txt que contiene todos los URLs que deseo descargar:

https://www.gesetze-im-internet.de/gg/art_1.html
https://www.gesetze-im-internet.de/gg/art_2.html
https://www.gesetze-im-internet.de/gg/art_3.html
https://www.gesetze-im-internet.de/gg/art_4.html
https://www.gesetze-im-internet.de/gg/art_5.html
https://www.gesetze-im-internet.de/gg/art_6.html
https://www.gesetze-im-internet.de/gg/art_7.html
https://www.gesetze-im-internet.de/gg/art_8.html
https://www.gesetze-im-internet.de/gg/art_9.html
https://www.gesetze-im-internet.de/gg/art_10.html
https://www.gesetze-im-internet.de/gg/art_11.html
https://www.gesetze-im-internet.de/gg/art_12.html
https://www.gesetze-im-internet.de/gg/art_12a.html
https://www.gesetze-im-internet.de/gg/art_13.html
https://www.gesetze-im-internet.de/gg/art_14.html
https://www.gesetze-im-internet.de/gg/art_15.html
https://www.gesetze-im-internet.de/gg/art_16.html
https://www.gesetze-im-internet.de/gg/art_16a.html
https://www.gesetze-im-internet.de/gg/art_17.html
https://www.gesetze-im-internet.de/gg/art_17a.html
https://www.gesetze-im-internet.de/gg/art_18.html
https://www.gesetze-im-internet.de/gg/art_19.html
urls.txt

Luego, escribí un poco de código en Python en un archivo llamado scraper.py para descargar el HTML de estos archivos.

En un escenario real, esto sería demasiado costoso y, en su lugar, utilizarías una base de datos. Para simplificar las cosas, descargaré archivos en el mismo directorio y usaré su nombre como nombre de archivo.

from os import path
from pathlib import PurePath

import requests

with open('urls.txt', 'r') as fh:
    urls = fh.readlines()
urls = [url.strip() for url in urls]  # strip `\n`

for url in urls:
    file_name = PurePath(url).name
    file_path = path.join('.', file_name)
    text = ''

    try:
        response = requests.get(url)
        if response.ok:
            text = response.text
    except requests.exceptions.ConnectionError as exc:
        print(exc)
    
    with open(file_path, 'w') as fh:
        fh.write(text)

    print('Written to', file_path)
scraper.py

Al descargar los archivos, los puedo procesar localmente tanto como lo desee sin depender de unos servidos. Trata de ser un buen ciudadano web, ¿si?

Paso 2: Analizar la fuente

Ahora que he descargado los archivos, es tiempo de extraer información interesante. Por lo tanto me dirijo a alguna de las páginas que descargué, la abro en un navegador web, y aprieto Crtl-U para ver su código fuente. Al inspeccionarlo me mostrará su estructura HTML.

En mi caso, quería el texto de la ley sin ningún marcado. El elemento que lo envuelve tiene un id de container. Usando BeautifulSoup puedo ver que una combinación de find y get_text hará lo que quiero.

Como tengo un segundo paso ahora, voy a refactorizar un poco el código poniéndolo en funciones y agregando una CLI mínima.

from os import path
from pathlib import PurePath
import sys

from bs4 import BeautifulSoup
import requests


def download_urls(urls, dir):
    paths = []

    for url in urls:
        file_name = PurePath(url).name
        file_path = path.join(dir, file_name)
        text = ''

        try:
            response = requests.get(url)
            if response.ok:
                text = response.text
            else:
                print('Mala respuesta para', url, response.status_code)
        except requests.exceptions.ConnectionError as exc:
            print(exc)
    
        with open(file_path, 'w') as fh:
            fh.write(text)

        paths.append(file_path)

    return paths

def parse_html(path):
    with open(path, 'r') as fh:
        content = fh.read()

    return BeautifulSoup(content, 'html.parser')

def download(urls):
    return download_urls(urls, '.')

def extract(path):
    return parse_html(path)

def transform(soup):
    container = soup.find(id='container')
    if container is not None:
        return container.get_text()

def load(key, value):
    d = {}
    d[key] = value
    return d

def run_single(path):
    soup = extract(path)
    content = transform(soup)
    unserialised = load(path, content.strip() if content is not None else '')
    return unserialised

def run_everything():
    l = []

    with open('urls.txt', 'r') as fh:
        urls = fh.readlines()
    urls = [url.strip() for url in urls]

    paths = download(urls)
    for path in paths:
        print('Written to', path)
        l.append(run_single(path))

    print(l)

if __name__ == "__main__":
    args = sys.argv

    if len(args) is 1:
      run_everything()
    else:
        if args[1] == 'download':
            download([args[2]])
            print('Done')
        if args[1] == 'parse':
            path = args[2]
            result = run_single(path)
            print(result)
scraper.py

Ahora puedo ejecutar el código de tres maneras:

  1. Sin ningún argumento para ejecutar todo (es decir, descargar todas las URL y extraerlas, luego guardarlas en el disco) a través de: python scraper.py
  2. Con un argumento de download y una url para descargar: python scraper.py download https://www.gesetze-im-internet.de/gg/art_1.html. Esto no procesará el archivo.
  3. Con un argumento de parse y una ruta de archivo para analizar: python scraper.py art_1.html. Esto omitirá el paso de descarga.

Con esto, solo falta una última cosa.

Paso 3: Dar formato a la fuente para su posterior procesamiento

Digamos que quiero generar una nube de palabras para cada artículo. Esta puede ser una forma rápida de tener una idea de lo que trata un texto. Para ello, instala el paquete wordcloud y actualiza el archivo así:

from os import path
from pathlib import Path, PurePath
import sys

from bs4 import BeautifulSoup
import requests
from wordcloud import WordCloud

STOPWORDS_ADDENDUM = [
    'Das',
    'Der',
    'Die',
    'Diese',
    'Eine',
    'In',
    'InhaltsverzeichnisGrundgesetz',
    'im',
    'Jede',
    'Jeder',
    'Kein',
    'Sie',
    'Soweit',
    'Über'
]
STOPWORDS_FILE_PATH = 'stopwords.txt'
STOPWORDS_URL = 'https://raw.githubusercontent.com/stopwords-iso/stopwords-de/master/stopwords-de.txt'


def download_urls(urls, dir):
    paths = []

    for url in urls:
        file_name = PurePath(url).name
        file_path = path.join(dir, file_name)
        text = ''

        try:
            response = requests.get(url)
            if response.ok:
                text = response.text
            else:
                print('Mala respuesta para', url, response.status_code)
        except requests.exceptions.ConnectionError as exc:
            print(exc)
    
        with open(file_path, 'w') as fh:
            fh.write(text)

        paths.append(file_path)

    return paths

def parse_html(path):
    with open(path, 'r') as fh:
        content = fh.read()

    return BeautifulSoup(content, 'html.parser')

def download_stopwords():
    stopwords = ''

    try:
        response = requests.get(STOPWORDS_URL)
        if response.ok:
            stopwords = response.text
        else:
            print('Mala respuesta para', url, response.status_code)
    except requests.exceptions.ConnectionError as exc:
        print(exc)

    with open(STOPWORDS_FILE_PATH, 'w') as fh:
        fh.write(stopwords)

    return stopwords

def download(urls):
    return download_urls(urls, '.')

def extract(path):
    return parse_html(path)

def transform(soup):
    container = soup.find(id='container')
    if container is not None:
        return container.get_text()

def load(filename, text):
    if Path(STOPWORDS_FILE_PATH).exists():
        with open(STOPWORDS_FILE_PATH, 'r') as fh:
            stopwords = fh.readlines()
    else:
        stopwords = download_stopwords()

    # Tira de espacios en blanco alrededor
    stopwords = [stopword.strip() for stopword in stopwords]
    # Extienda las stopwords con las propias, que se determinaron después de 	  la primera ejecución stopwords = stopwords + STOPWORDS_ADDENDUM

    try:
        cloud = WordCloud(stopwords=stopwords).generate(text)
        cloud.to_file(filename.replace('.html', '.png'))
    except ValueError:
        print('No se pudo generar la nube de palabras para', key)

def run_single(path):
    soup = extract(path)
    content = transform(soup)
    load(path, content.strip() if content is not None else '')

def run_everything():
    with open('urls.txt', 'r') as fh:
        urls = fh.readlines()
    urls = [url.strip() for url in urls]

    paths = download(urls)
    for path in paths:
        print('Written to', path)
        run_single(path)
    print('Done')

if __name__ == "__main__":
    args = sys.argv

    if len(args) is 1:
      run_everything()
    else:
        if args[1] == 'download':
            download([args[2]])
            print('Done')
        if args[1] == 'parse':
            path = args[2]
            run_single(path)
            print('Done')
scraper.py

¿Qué cambió? Por un lado, descargué una lista de palabras vacías (stopwords) alemanas de GitHub. De esta manera, puedo eliminar las palabras más comunes del texto de la ley descargado.

Luego, creé una instancia de WordCloud con la lista de palabras vacías que descargué y el texto de la ley. Se convertirá en una imagen con el mismo nombre de base.

Después de la primera ejecución, descubrí que la lista de palabras vacías está incompleta. Así que agregué palabras adicionales que quiero excluir de la imagen resultante.

Con esto, la parte principal de la extracción web está completa.

Bonus: ¿Y los SPA?

Los SPA, o aplicaciones de página única, son aplicaciones web donde toda la experiencia está controlada por JavaScript, que se ejecuta en el navegador. Como tal, descargar el archivo HTML no nos lleva muy lejos. ¿Qué deberíamos hacer en su lugar?

Usaremos el navegador. Con Selenium. Asegúrate de instalar también un controlador. Descarga el archivo .tar.gz y descomprímelo en la carpeta bin de tu entorno virtual para que Selenium lo encuentre. Ese es el directorio donde puedes encontrar el script de activación (en sistemas GNU / Linux).

Como ejemplo, estoy usando el sitio web de Angular aquí. Angular es un SPA-Framework popular escrito en JavaScript y se garantiza que será controlado por él por el momento.

Dado que el código será más lento, creé un nuevo archivo llamado crawler.py para él. El contenido tiene este aspecto:

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from wordcloud import WordCloud

def extract(url):
    elem = None
    driver = webdriver.Firefox()
    driver.get(url)

    try:
        found = WebDriverWait(driver, 10).until(
            EC.visibility_of(
                driver.find_element(By.TAG_NAME, "article")
            )
        )
        # Haz una copia de los datos relevantes, porque Selenium arrojará si
        # intenta acceder a las propiedades después de que el controlador se 			cierre
        elem = {
          "text": found.text
        }
    finally:
        driver.close()

    return elem

def transform(elem):
    return elem["text"]
        
def load(text, filepath):
    cloud = WordCloud().generate(text)
    cloud.to_file(filepath)

if __name__ == "__main__":
    url = "https://angular.io/"
    filepath = "angular.png"

    elem = extract(url)
    if elem is not None:
        text = transform(elem)
        load(text, filepath)
    else:
        print("Lo siento, no se pudieron extraer datos")
crawler.py

Aquí, Python abre una instancia de Firefox, navega por el sitio web y busca un elemento <article>. Está copiando su texto en un diccionario, que se lee en el paso transform y se convierte en WordCloud durante load.

Cuando se trata de sitios con mucho JavaScript, a menudo es útil usar Waits y tal vez incluso ejecutar execute_script para diferir a JavaScript si es necesario.

Resumen

¡Gracias por leer hasta aquí! Resumamos lo que hemos aprendido ahora:

  1. Cómo hacer una extracción (scrape) un sitio web con el paquete requests de Python.
  2. Cómo traducirla en una estructura con sentido usando beautifulsoup.
  3. Cómo procesar aún más esa estructura en algo con lo que puedas trabajar.
  4. Qué hacer si la página de destino se basa en JavaScript.

Otras lecturas

Si quieres saber más sobre mí, puedes seguirme en Twitter o visitar mi sitio web.

No soy el primero que escribió sobre Web Scraping aquí en freeCodeCamp. Yasoob Khalid y Dave Gray también lo hicieron en el pasado:

An Intro to Web Scraping with lxml and Python
by Timber.io An Intro to Web Scraping with lxml and PythonPhoto by Fabian Grohs[https://unsplash.com/photos/dC6Pb2JdAqs?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText] on Unsplash[https://unsplash.com/search/photos/web?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText…
0*AXQfWm6LMJwLwS2f
Better web scraping in Python with Selenium, Beautiful Soup, and pandas
by Dave Gray Web ScrapingUsing the Python programming language, it is possible to “scrape” data from theweb in a quick and efficient manner. Web scraping is defined as: &gt; a tool for turning the unstructured data on the web into machine readable,structured data which is ready for analysis. (sou…
1*DiffPQdgEAjDK4M_unUd4Q

Traducido del artículo de André Jaenisch - How Scrape Websites with Python 3