ProxynetProxynet

Cómo limpiar datos de scraping con pandas en Python

Publicado:

16 min de lectura

Acar Diveroli
Autor: Acar Diveroli
Un gran NaN entre guías de tipos y una cinta azul rayada como una tabla; tarjetas convierten un precio en texto en 51.77.

Tu scraper terminó durante la noche y el CSV parece correcto a primera vista. Luego intentas calcular el precio medio y pandas se niega, porque cada precio es un texto como £51.77. El mismo libro aparece dos veces porque estaba en una página del catálogo y en una página de categoría. Un título dice Shakespeare’s, siete libros están en una categoría llamada "Default" y una fila no tiene descripción. Nada de esto es un fallo del scraper. Así son los datos web en bruto, y necesitan una pasada de limpieza antes de que alguien haga un gráfico con ellos.

Esta guía recorre esa pasada con pandas sobre un conjunto de datos real extraído de books.toscrape.com, un sitio de pruebas creado para practicar web scraping. Tratamos la reparación de texto y Unicode, los valores faltantes, la conversión de precios en texto a números, otros números escondidos en el texto, las fechas, las etiquetas de categoría, los duplicados, los valores atípicos, las comprobaciones de validación y el guardado del resultado. El script completo se ejecutó el 28 de septiembre de 2026 con pandas 3.0.6, Requests 2.34.2, beautifulsoup4 4.15.0, pyarrow 25.0.1 y Python 3.13.

¿Qué significa "limpio" en datos de scraping?

Los datos limpios tienen una fila por cada cosa del mundo real, un tipo por columna y una sola grafía por valor. Una columna de precios contiene números, no texto con un símbolo de moneda. Un libro extraído dos veces aparece una sola vez. Una descripción que falta se registra como faltante, no como una cadena vacía que parece presente. Una categoría llamada "Default" recibe un nombre que le dice al analista que no aporta información.

Limpio no significa completo ni correcto en cada detalle. No puedes recuperar una descripción que la página nunca tuvo, y un script de limpieza no debe inventarla. Su trabajo es hacer visible cada problema y fiable cada valor restante, para que el paso siguiente (un gráfico, un modelo, una alerta de precio) trabaje con hechos y no con accidentes de parseo.

¿Por qué los datos de scraping están desordenados?

Las páginas web se escriben para personas, y un scraper lee las partes pensadas para la vista. Casi todo el desorden viene de unas pocas fuentes:

  • Todo es texto. El HTML no lleva tipos. £51.77, In stock (22 available) y Three (una clase CSS para la valoración en estrellas) llegan todos como cadenas.
  • La codificación se adivina mal. books.toscrape.com envía Content-Type: text/html sin charset. En ese caso, Requests sigue la antigua regla de HTTP/1.1 y decodifica la página como ISO-8859-1 (documentación de Requests sobre codificaciones), así que el símbolo de libra en UTF-8 se convierte en £. El mecanismo se explica en errores de codificación en Python.
  • El mismo artículo tiene varias rutas. Un producto aparece en el catálogo, en su categoría y a veces en una lista de "novedades" u "ofertas". Si rastreas más de una, lo recoges dos veces. Una paginación que se desplaza entre ejecuciones produce el mismo efecto.
  • Las plantillas cambian. Una página de cada mil carece de un bloque que las demás tienen, y tu selector devuelve None.
  • Los datos del sitio son imperfectos. Las categorías de relleno, los espacios sobrantes y las colas "...more" forman parte de la fuente, no son errores tuyos.

¿Cómo funciona una pasada de limpieza?

El orden importa, porque cada paso depende del anterior:

  1. Conserva los datos en bruto. Guarda exactamente lo que recogió el scraper en books_raw.csv. La limpieza trabaja sobre una copia, así que puedes repetirla con reglas nuevas sin volver a hacer scraping.
  2. Normaliza el texto. Quita los espacios de ambos extremos, convierte las cadenas vacías en valores faltantes, repara la codificación rota y aplica la normalización Unicode.
  3. Cuenta los valores faltantes. Regístralos en un informe y añade indicadores; no los rellenes con suposiciones.
  4. Convierte los precios. Separa el símbolo de moneda en su propia columna y convierte el resto en un float.
  5. Extrae otros números. Unidades en stock de In stock (22 available), valoraciones a partir de palabras, número de reseñas a partir de texto.
  6. Parsea las fechas como marcas de tiempo con zona horaria.
  7. Normaliza las categorías para que un concepto tenga una sola etiqueta.
  8. Elimina duplicados con una clave estable, una vez que los tipos son correctos.
  9. Marca los valores atípicos con una regla sencilla que puedas explicar.
  10. Valida y guarda. Detente si falla una comprobación; si no, escribe CSV y Parquet.

La deduplicación va al final a propósito. Dos filas que solo se diferencian por un espacio final o por un apóstrofo mal decodificado son el mismo libro, y drop_duplicates solo lo detecta después de los pasos 2 a 7.

Valores en bruto y limpios, uno al lado del otro

Estos son valores reales del scraping y aquello en lo que los convierte el script:

ColumnaValor en brutoProblemaValor limpioHerramienta de pandas
price£51.77Texto, codificación rota51.77 (float64) + currency = £str.extract, pd.to_numeric
availabilityIn stock (22 available)Número dentro de una frasein_stock = 22 (Int64)str.extract
ratingThreePalabra, no número3 (Int64)map
titleShakespeare’s GlobeMojibakeShakespeare’s Globeencode/decode, unicodedata
categoryDefaultEtiqueta de rellenoUncategorized (category)replace, astype("category")
description… and their affair begins. ...moreRestos de la página en el texto… and their affair begins.str.removesuffix
scraped_at2026-09-28T14:51:11+00:00Cadenadatetime64[us, UTC]pd.to_datetime
upcmismo código en dos filasLibro duplicadouna fila, la copia más recientedrop_duplicates

"Mojibake" es el nombre del texto ilegible que aparece cuando unos bytes se decodifican con la codificación equivocada, como ’ en lugar de ’.

¿Qué cambió en pandas 3.0?

pandas 3.0.0 se publicó el 21 de enero de 2026 y la versión actual es la 3.0.6. Tres cambios de las notas de la versión 3.0 de pandas afectan directamente al código de limpieza:

  • Las cadenas tienen su propio tipo. Las columnas de texto ahora se infieren como str en lugar de object, y sus valores faltantes son NaN, como en los demás tipos por defecto. Por eso el script selecciona las columnas de texto con select_dtypes(include=["object", "string"]): funciona en pandas 2 y 3.
  • Copy-on-Write es el único modo. Una asignación encadenada como df["price"][0] = 10 ya no modifica df; pandas 3.0.6 solo muestra una advertencia ChainedAssignmentError. Usa df.loc[0, "price"] = 10. La antigua SettingWithCopyWarning desaparece, y las llamadas defensivas a .copy() que se escribían para silenciarla ya no hacen falta.
  • Las fechas parseadas usan microsegundos por defecto. pd.to_datetime sobre cadenas devuelve ahora datetime64[us] en lugar de nanosegundos, que es lo que verás en la salida más abajo.

pandas 3 también requiere Python 3.11 o posterior. pd.to_numeric solo acepta errors="raise" o errors="coerce" (referencia de to_numeric); los fragmentos antiguos que pasan errors="ignore" fallarán.

El script completo: scraping de books.toscrape.com y limpieza

Instala las bibliotecas en un entorno virtual. pyarrow es necesario para el archivo Parquet:

bash
python -m venv .venv
.venv/bin/pip install pandas requests beautifulsoup4 pyarrow   # Windows: .venv\Scripts\pip

El scraper lee dos páginas del catálogo y dos páginas de categoría (Poetry y Classics), 78 libros en total, con una pausa de un segundo entre peticiones y reintentos automáticos ante respuestas 429 y 5xx. El parseo del HTML sigue el enfoque de nuestro tutorial de BeautifulSoup. La segunda ejecución lee books_raw.csv y solo repite la limpieza.

python
"""Scrape books.toscrape.com, then clean the result with pandas."""
import time
import unicodedata
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin

import pandas as pd
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

BASE = "https://books.toscrape.com/"
START_URLS = [
    BASE + "catalogue/page-1.html",                             # catálogo principal
    BASE + "catalogue/category/books/poetry_23/index.html",     # una categoría
    BASE + "catalogue/category/books/classics_6/index.html",    # otra más
]
MAX_PAGES = 2          # páginas de lista por URL inicial
DELAY = 1.0            # segundos entre peticiones
RAW_FILE = Path("books_raw.csv")

session = requests.Session()
session.headers["User-Agent"] = "pandas-cleaning-tutorial/1.0 (contact: you@example.com)"
retry = Retry(total=4, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
session.mount("https://", HTTPAdapter(max_retries=retry))
# session.proxies = {"https": "http://user:pass@pr.proxynet.io:8000"}


def get(url):
    time.sleep(DELAY)
    resp = session.get(url, timeout=30)
    resp.raise_for_status()
    return resp.text   # decodificado con la suposición de Requests, a propósito: ver paso 2


def parse_book(url):
    soup = BeautifulSoup(get(url), "html.parser")
    table = {th.get_text(): td.get_text() for th, td in
             zip(soup.select("table.table th"), soup.select("table.table td"))}
    desc = soup.select_one("#product_description + p")
    return {
        "url": url,
        "title": soup.h1.get_text(),
        "category": soup.select("ul.breadcrumb a")[-1].get_text(),
        "price": table.get("Price (incl. tax)"),
        "availability": table.get("Availability"),
        "rating": soup.select_one("p.star-rating")["class"][1],
        "upc": table.get("UPC"),
        "reviews": table.get("Number of reviews"),
        "description": desc.get_text() if desc else None,
        "scraped_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    }


def scrape():
    rows = []
    for start in START_URLS:
        url = start
        for _ in range(MAX_PAGES):
            soup = BeautifulSoup(get(url), "html.parser")
            for a in soup.select("article.product_pod h3 a"):
                rows.append(parse_book(urljoin(url, a["href"])))
            nxt = soup.select_one("li.next a")
            if not nxt:
                break
            url = urljoin(url, nxt["href"])
    df = pd.DataFrame(rows)
    df.to_csv(RAW_FILE, index=False, encoding="utf-8")
    return df


def clean(raw):
    df = raw.copy()
    report = {"raw_rows": len(df)}

    # 1. Quitar espacios en cada columna de texto; las cadenas vacías pasan a faltantes
    text_cols = df.select_dtypes(include=["object", "string"]).columns
    for col in text_cols:
        df[col] = df[col].str.strip().replace("", pd.NA)

    # 2. Unicode: reparar el mojibake y normalizar a NFKC
    def fix_text(s):
        if pd.isna(s):
            return s
        try:
            s = s.encode("latin-1").decode("utf-8")   # "£" -> "£", "é" -> "é"
        except (UnicodeEncodeError, UnicodeDecodeError):
            pass                                        # ya es correcto
        return unicodedata.normalize("NFKC", s)

    for col in ["title", "category", "description", "price"]:
        df[col] = df[col].map(fix_text)

    # 3. Valores faltantes: contarlos, marcarlos, no inventarlos
    report["missing_before"] = df.isna().sum()[lambda s: s > 0].to_dict()
    df["description"] = df["description"].str.removesuffix("...more").str.strip()
    df["has_description"] = df["description"].notna()

    # 4. Precios en texto a números
    df["currency"] = df["price"].str.extract(r"([£$€])", expand=False)
    df["price"] = pd.to_numeric(
        df["price"].str.replace(r"[^\d.]", "", regex=True), errors="coerce"
    )

    # 5. Otros números escondidos en el texto
    df["in_stock"] = pd.to_numeric(
        df["availability"].str.extract(r"(\d+)\s+available", expand=False),
        errors="coerce",
    ).astype("Int64")
    df["rating"] = df["rating"].map(
        {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
    ).astype("Int64")
    df["reviews"] = pd.to_numeric(df["reviews"], errors="coerce").astype("Int64")

    # 6. Fechas
    df["scraped_at"] = pd.to_datetime(df["scraped_at"], utc=True, errors="coerce")

    # 7. Categorías: una grafía por valor, etiquetas basura fusionadas
    df["category"] = (df["category"]
                      .replace({"Default": "Uncategorized",
                                "Add a comment": "Uncategorized"})
                      .astype("category"))

    # 8. Duplicados: mismo UPC = mismo libro; conservar la copia más reciente
    report["duplicate_rows"] = int(df.duplicated("upc").sum())
    df = (df.sort_values("scraped_at")
            .drop_duplicates("upc", keep="last")
            .reset_index(drop=True))

    # 9. Valores atípicos: marcar, no borrar
    q1, q3 = df["price"].quantile([0.25, 0.75])
    iqr = q3 - q1
    df["price_outlier"] = ~df["price"].between(q1 - 1.5 * iqr, q3 + 1.5 * iqr)
    report["price_outliers"] = int(df["price_outlier"].sum())

    df = df.drop(columns=["availability"])
    report["clean_rows"] = len(df)
    return df, report


def validate(df):
    problems = []
    if df["upc"].duplicated().any():
        problems.append("duplicate UPCs")
    if df["price"].isna().any():
        problems.append("prices that did not parse")
    if not df["price"].dropna().between(0, 1000).all():
        problems.append("prices outside 0-1000")
    if not df["rating"].between(1, 5).fillna(False).all():
        problems.append("ratings missing or outside 1-5")
    if df["title"].isna().any():
        problems.append("rows without a title")
    if problems:
        raise ValueError("Validation failed: " + ", ".join(problems))


if __name__ == "__main__":
    raw = pd.read_csv(RAW_FILE) if RAW_FILE.exists() else scrape()
    clean_df, report = clean(raw)
    validate(clean_df)
    clean_df.to_csv("books_clean.csv", index=False, encoding="utf-8-sig")
    clean_df.to_parquet("books_clean.parquet", index=False)
    print(report)
    print(clean_df.dtypes)
    print(clean_df[["title", "category", "price", "rating", "in_stock"]].head())

La primera ejecución tardó unos dos minutos, casi todo por la pausa de cortesía. La línea de informe que imprimió:

text
{'raw_rows': 78, 'missing_before': {'description': 1}, 'duplicate_rows': 5, 'price_outliers': 0, 'clean_rows': 73}

Cinco libros de Poetry estaban tanto en las páginas del catálogo como en la página de Poetry, así que 78 filas se quedaron en 73. Un título de Classics, Alice in Wonderland, no tiene bloque de descripción en su página. Siete libros llevaban la categoría "Default". Los precios iban de 12,84 a 58,63, así que la regla de valores atípicos no marcó nada, que es la respuesta correcta para este sitio. La salida de dtypes muestra str para el texto, float64 para el precio, Int64 para los recuentos, category para la categoría y datetime64[us, UTC] para la marca de tiempo.

Qué hace cada paso y por qué

Texto y Unicode

str.strip() elimina los espacios y saltos de línea que la maquetación HTML deja alrededor de los valores. Convertir "" en un valor faltante importa más de lo que parece: isna() no cuenta las cadenas vacías, así que sin ese paso un campo en blanco pasa por relleno.

La reparación de la codificación deshace la suposición equivocada. s.encode("latin-1").decode("utf-8") toma los caracteres que produjo Requests, los vuelve a convertir en los bytes originales y decodifica esos bytes como UTF-8. Una cadena que ya era correcta pasa sin cambios (si es ASCII puro) o lanza un error en una de las dos llamadas y se deja como está. La solución más limpia está en el origen: pasa response.content a BeautifulSoup o fija response.encoding = "utf-8" antes de leer response.text. Aun así, conviene conocer la reparación para los archivos que ya guardaste. Después, unicodedata.normalize("NFKC", s) pliega los caracteres de compatibilidad, como las ligaduras y los espacios de no separación, a sus formas simples (documentación de unicodedata), para que dos grafías del mismo título se comparen como iguales.

Valores faltantes

El script cuenta los valores faltantes por columna y añade un indicador has_description. No escribe "Sin descripción" en el hueco, porque esa cadena parecería después texto real. En el caso de los números, rellenar huecos con la media o la mediana corresponde al paso de análisis, donde el analista puede indicarlo en el gráfico, no al paso de limpieza.

Números, valoraciones y fechas

pd.to_numeric(..., errors="coerce") convierte en NaN todo lo que no puede parsear, en lugar de detenerse. Es el comportamiento adecuado para una pasada de limpieza, siempre que una comprobación de validación posterior cuente esos NaN. El tipo anulable Int64 (con I mayúscula) mantiene los enteros como enteros aunque falte un valor; el int64 normal no puede contener NaN y se convertiría en float sin avisar. Las fechas se parsean con utc=True para que las marcas de tiempo de distintas ejecuciones y máquinas se comparen correctamente.

Categorías y duplicados

astype("category") guarda cada etiqueta una sola vez y un código pequeño por fila. Además del ahorro de memoria, hace explícita la lista de etiquetas: df["category"].cat.categories muestra de un vistazo si "Poetry" y "poetry " han sobrevivido las dos.

Para los duplicados, elige una clave que identifique la cosa, no la fila. Aquí es el UPC que figura en cada página de libro. La URL también serviría en este sitio, pero en tiendas reales el mismo producto suele tener varias URL (parámetros de seguimiento, rutas de categoría). Ordenar por scraped_at antes de drop_duplicates(keep="last") conserva la copia más reciente, que es lo que quieres cuando los precios cambian entre ejecuciones.

Valores atípicos y validación

La regla del rango intercuartílico (IQR) marca los valores que quedan más de 1,5 veces el IQR por debajo del primer cuartil o por encima del tercero. Es fácil de explicar y no supone una distribución normal. El script añade una columna price_outlier en lugar de borrar filas: un precio de 0.99 en una tienda real puede ser una oferta auténtica o un error de parseo que leyó "0.99" en "10.99". Una persona debería revisarlo antes de borrar nada.

validate() es la última línea de defensa. Cuando cambiamos un precio en bruto a N/A y una valoración a Six, la ejecución se detuvo con Validation failed: prices that did not parse, ratings missing or outside 1-5 en lugar de escribir un archivo defectuoso. Mantén estas comprobaciones cerca de las reglas reales de tus datos; una comprobación que nunca puede fallar no protege nada.

Dónde se usan los datos de scraping limpios

Errores frecuentes

  • Limpiar la única copia. Si sobrescribes el archivo en bruto, cada cambio de regla exige un nuevo scraping.
  • Deduplicar primero. drop_duplicates compara valores exactos, así que "Olio" y "Olio " sobreviven los dos si lo ejecutas antes de quitar los espacios.
  • astype(float) sobre precios en texto. Falla con el primer £. Elimina los caracteres no numéricos, usa pd.to_numeric con errors="coerce" y cuenta los NaN resultantes.
  • Rellenar huecos con cero. Un recuento de stock que falta no es stock cero. Usa Int64 y déjalo como faltante.
  • Asignación encadenada. En pandas 3, df[df.price > 50]["flag"] = True no cambia nada y solo avisa. Usa df.loc[df.price > 50, "flag"] = True.
  • Guardar para Excel sin BOM. Excel en Windows lee un CSV UTF-8 simple con una página de códigos heredada y el £ se rompe de nuevo; encoding="utf-8-sig" lo evita.
  • Hacer más scraping en lugar de limpiar mejor. Si faltan datos porque fallaron peticiones, arregla la recolección: respeta robots.txt, ve más despacio ante un HTTP 429 y prefiere una API oficial cuando exista.

Guía de decisión

NecesidadRecomendación
Números guardados como textoQuita los símbolos, pd.to_numeric(errors="coerce") y cuenta los NaN
Enteros con huecosInt64 anulable, no int64 ni float
Texto con £, ’ o éCorrige la decodificación en el origen; repara los archivos guardados con el viaje de ida y vuelta por latin-1
El mismo artículo desde varias páginasdrop_duplicates sobre un ID estable, conservando la copia más reciente
Algunos precios rarosMárcalos con la regla del IQR y revísalos; no los borres automáticamente
Etiquetas repetidas, como categoríasastype("category") y un mapeo explícito para las de relleno
Marcas de tiempo de distintas ejecucionespd.to_datetime(..., utc=True)
Conservar los tipos entre pasosGuarda Parquet junto al CSV
Scraping grande o recurrenteLímites de velocidad, reintentos y, cuando el volumen lo exija, Proxies rotativos a través de pr.proxynet.io:8000

Preguntas frecuentes

¿Debo limpiar los datos durante el scraping o después?

Haz lo mínimo durante el scraping (toma el texto y quítale los espacios) y el resto después, en pandas. Conservar el archivo en bruto te permite cambiar las reglas de limpieza y volver a aplicarlas en segundos sin enviar ni una petición nueva al sitio.

¿Por qué mi columna de precios muestra £ en lugar de £?

El servidor no declaró un charset, así que Requests decodificó bytes UTF-8 como ISO-8859-1. Pasa response.content a tu parser o fija response.encoding = "utf-8". Para datos ya guardados, s.encode("latin-1").decode("utf-8") deshace el error.

¿Es dropna() la forma correcta de tratar los valores faltantes?

Solo cuando una fila sin ese campo no sirve para tu pregunta. dropna() sin argumentos elimina una fila si falta cualquier columna, lo que puede borrar la mayor parte de un conjunto de datos de scraping por culpa de un solo campo opcional. Usa dropna(subset=[...]) para las columnas que de verdad necesitas y marca las demás.

¿Cómo elijo la columna para eliminar duplicados?

Usa un identificador que figure en la página, como un UPC, SKU, ISBN o ID de producto. Las URL solo sirven en sitios donde cada artículo tiene exactamente una URL. Si no hay ID, combina varios campos normalizados, por ejemplo el título y la marca.

¿Funciona este código con pandas 2?

Solo lo probamos con pandas 3.0.6, pero todas las llamadas que usa existen también en pandas 2.x, y la selección de columnas de texto cubre tanto el antiguo tipo object como el nuevo tipo str. Espera pequeñas diferencias en la salida impresa, como object en lugar de str y marcas de tiempo en nanosegundos en lugar de microsegundos. El código que depende de la asignación encadenada es lo principal que se comporta distinto entre las dos versiones.

¿Necesito un proxy para limpiar datos?

No. La limpieza se ejecuta en tu equipo y no genera tráfico. Un proxy solo importa para la recolección, y solo con un volumen en el que una única dirección IP superaría los límites de velocidad de un sitio. El código de limpieza es el mismo sea cual sea la red por la que llegaron las páginas.

En resumen

Los datos de scraping llegan como texto pensado para lectores humanos. Una pasada de limpieza fiable conserva el archivo en bruto, corrige primero el texto y la codificación, convierte cada columna a un tipo real, elimina duplicados con una clave estable, marca los valores raros en lugar de borrarlos y se detiene ante una comprobación fallida antes de guardar. El script de arriba hace todo eso con 78 filas reales en menos de un segundo una vez descargadas las páginas. Cuando tu recolección vaya más allá de un sitio de práctica, los planes de nuestra página de proxy cubren la parte de red.