---
title: "Cómo limpiar datos de scraping con pandas en Python"
description: "Limpia datos de scraping con pandas: primero el texto, luego tipos, duplicados, valores faltantes y atípicos, y valida antes de guardar. Con script probado."
url: https://proxynet.io/es/blog/clean-scraped-data-with-pandas
date: 2026-09-28
author: "Acar Diveroli"
category: "Tutoriales, Web scraping"
lang: es
---

# Cómo limpiar datos de scraping con pandas en Python

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](https://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.

> **Nota: Respuesta breve**
>
> Carga las filas en bruto en un DataFrame y deja ese archivo original sin tocar. Limpia primero el texto: quita espacios, convierte las cadenas vacías en valores faltantes y repara la codificación rota. Después convierte las columnas a tipos reales con `pd.to_numeric(..., errors="coerce")`, `pd.to_datetime(..., utc=True)`, el tipo anulable `Int64` y `category`. Luego elimina duplicados con una clave estable, como un código de producto, y conserva la copia más reciente. Marca los valores atípicos en lugar de borrarlos, ejecuta algunas comprobaciones al estilo `assert` que detengan el script cuando algo falle y guarda el resultado en CSV y Parquet.

## ¿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](https://requests.readthedocs.io/en/latest/user/advanced/#encodings)), así que el símbolo de libra en UTF-8 se convierte en `Â£`. El mecanismo se explica en [errores de codificación en Python](/es/blog/python-unicode-encoding-errors).
- **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](/es/blog/pagination-web-scraping) 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:

| Columna | Valor en bruto | Problema | Valor limpio | Herramienta de pandas |
|---|---|---|---|---|
| `price` | `Â£51.77` | Texto, codificación rota | `51.77` (float64) + `currency` = `£` | `str.extract`, `pd.to_numeric` |
| `availability` | `In stock (22 available)` | Número dentro de una frase | `in_stock` = `22` (Int64) | `str.extract` |
| `rating` | `Three` | Palabra, no número | `3` (Int64) | `map` |
| `title` | `Shakespeareâ€™s Globe` | Mojibake | `Shakespeare’s Globe` | `encode`/`decode`, `unicodedata` |
| `category` | `Default` | Etiqueta de relleno | `Uncategorized` (category) | `replace`, `astype("category")` |
| `description` | `… and their affair begins. ...more` | Restos de la página en el texto | `… and their affair begins.` | `str.removesuffix` |
| `scraped_at` | `2026-09-28T14:51:11+00:00` | Cadena | `datetime64[us, UTC]` | `pd.to_datetime` |
| `upc` | mismo código en dos filas | Libro duplicado | una fila, la copia más reciente | `drop_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](https://pandas.pydata.org/docs/whatsnew/v3.0.0.html) 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](https://pandas.pydata.org/docs/reference/api/pandas.to_numeric.html)); 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](/es/blog/beautifulsoup-tutorial). 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](https://docs.python.org/3/library/unicodedata.html)), 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

- **Monitorización de precios:** los precios numéricos con marca de tiempo son lo que necesitan un historial de precios y una alerta. Consulta [monitorización de precios](/es/price-monitoring) y [seguimiento de precios de la competencia](/es/blog/competitor-price-tracking).
- **Estudios de mercado:** cuotas por categoría, distribución de valoraciones y niveles de stock en todo un catálogo. Consulta [estudios de mercado](/es/market-research).
- **Minería de datos y modelado:** los modelos necesitan entradas tipadas y sin duplicados. Contexto en [minería de datos](/es/blog/what-is-data-mining).
- **Pipelines:** este script es el paso de "transformación" de un pequeño job ETL; el patrón completo está en [¿Qué es ETL?](/es/blog/what-is-etl).
- **Almacenamiento:** una vez que las columnas tienen tipo, cómo elegir entre CSV, JSON y SQLite se explica en [cómo guardar datos de scraping en CSV, JSON y SQLite](/es/blog/save-scraped-data-csv-json-sqlite).
- **Recolección a escala:** los proyectos de scraping más grandes y la infraestructura que los rodea están en nuestra página de [data scraping](/es/data-scraping).

## 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](/es/blog/robots-txt), ve más despacio ante un [HTTP 429](/es/blog/http-429-too-many-requests) y prefiere una API oficial cuando exista.

## Guía de decisión

| Necesidad | Recomendación |
|---|---|
| Números guardados como texto | Quita los símbolos, `pd.to_numeric(errors="coerce")` y cuenta los `NaN` |
| Enteros con huecos | `Int64` 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áginas | `drop_duplicates` sobre un ID estable, conservando la copia más reciente |
| Algunos precios raros | Márcalos con la regla del IQR y revísalos; no los borres automáticamente |
| Etiquetas repetidas, como categorías | `astype("category")` y un mapeo explícito para las de relleno |
| Marcas de tiempo de distintas ejecuciones | `pd.to_datetime(..., utc=True)` |
| Conservar los tipos entre pasos | Guarda Parquet junto al CSV |
| Scraping grande o recurrente | Límites de velocidad, reintentos y, cuando el volumen lo exija, [Proxies rotativos](https://proxynet.io/es/rotating-proxy) 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](/es/proxy) cubren la parte de red.
