ProxynetProxynet

Como limpar dados de scraping com pandas em Python

Publicado:

15 min de leitura

Acar Diveroli
Autor: Acar Diveroli
Um grande NaN entre guias de tipos e uma faixa azul pautada como tabela; cartões mostram um preço em texto virando 51.77.

Seu scraper terminou durante a noite e o CSV parece certo à primeira vista. Aí você tenta calcular a média dos preços e o pandas se recusa, porque cada preço é um texto como £51.77. O mesmo livro aparece duas vezes porque estava numa página do catálogo e numa página de categoria. Um título aparece como Shakespeare’s, sete livros estão numa categoria chamada "Default" e uma linha não tem descrição nenhuma. Nada disso é bug do scraper. É assim que dados brutos da web são, e eles precisam de uma etapa de limpeza antes que alguém faça um gráfico com eles.

Este guia percorre essa etapa com pandas usando um conjunto de dados real coletado de books.toscrape.com, um site de testes feito para praticar web scraping. Tratamos reparo de texto e Unicode, valores ausentes, conversão de preços em texto para números, outros números escondidos no texto, datas, rótulos de categoria, duplicatas, valores discrepantes (outliers), verificações de validação e o salvamento do resultado. O script completo rodou em 28 de setembro de 2026 com pandas 3.0.6, Requests 2.34.2, beautifulsoup4 4.15.0, pyarrow 25.0.1 e Python 3.13.

O que significa "limpo" para dados de scraping?

Dados limpos têm uma linha por coisa do mundo real, um tipo por coluna e uma grafia por valor. Uma coluna de preços guarda números, não texto com símbolo de moeda. Um livro coletado duas vezes aparece uma vez só. Uma descrição que falta é registrada como ausente, não como uma string vazia que parece preenchida. Uma categoria chamada "Default" ganha um nome que diz ao analista que ela não traz informação.

Limpo não quer dizer completo nem correto em cada detalhe. Você não consegue recuperar uma descrição que a página nunca teve, e um script de limpeza não deve inventá-la. O trabalho dele é tornar cada problema visível e cada valor restante confiável, para que a próxima etapa (um gráfico, um modelo, um alerta de preço) trabalhe com fatos e não com acidentes de parsing.

Por que dados de scraping são bagunçados?

Páginas web são escritas para pessoas, e um scraper lê as partes feitas para os olhos. Quase toda a bagunça vem de poucas fontes:

  • Tudo é texto. HTML não tem tipos. £51.77, In stock (22 available) e Three (uma classe CSS da avaliação em estrelas) chegam todos como strings.
  • O palpite de codificação erra. books.toscrape.com envia Content-Type: text/html sem charset. Nesse caso, o Requests segue a antiga regra do HTTP/1.1 e decodifica a página como ISO-8859-1 (documentação do Requests sobre codificações), então o símbolo de libra em UTF-8 vira £. O mecanismo é explicado em erros de codificação no Python.
  • O mesmo item tem vários caminhos. Um produto aparece no catálogo, na sua categoria e às vezes numa lista de "novidades" ou "promoções". Rastreie mais de uma delas e você o coleta duas vezes. Uma paginação que muda entre execuções faz o mesmo.
  • Os templates variam. Uma página em mil não tem um bloco que as outras têm, e seu seletor devolve None.
  • Os dados do site são imperfeitos. Categorias de preenchimento, espaços sobrando e finais "...more" fazem parte da fonte, não são erros seus.

Como funciona uma etapa de limpeza?

A ordem importa, porque cada passo depende do anterior:

  1. Guarde os dados brutos. Salve exatamente o que o scraper coletou em books_raw.csv. A limpeza trabalha numa cópia, então você pode reexecutá-la com regras novas sem fazer scraping de novo.
  2. Normalize o texto. Remova espaços das duas pontas, transforme strings vazias em valores ausentes, conserte a codificação quebrada e aplique a normalização Unicode.
  3. Conte os valores ausentes. Registre-os num relatório e adicione indicadores; não os preencha com palpites.
  4. Converta os preços. Separe o símbolo da moeda numa coluna própria e transforme o resto em float.
  5. Extraia outros números. Quantidade em estoque de In stock (22 available), avaliações a partir de palavras, número de resenhas a partir de texto.
  6. Faça o parsing das datas como timestamps com fuso horário.
  7. Normalize as categorias para que um conceito tenha um só rótulo.
  8. Remova duplicatas por uma chave estável, depois que os tipos estiverem certos.
  9. Sinalize outliers com uma regra simples que você consiga explicar.
  10. Valide e salve. Pare se uma verificação falhar; caso contrário, grave CSV e Parquet.

A deduplicação vem no fim de propósito. Duas linhas que só diferem por um espaço no final ou por um apóstrofo mal decodificado são o mesmo livro, e drop_duplicates só percebe isso depois dos passos 2 a 7.

Valores brutos e limpos lado a lado

Estes são valores reais da coleta e o que o script faz com eles:

ColunaValor brutoProblemaValor limpoFerramenta do pandas
price£51.77Texto, codificação quebrada51.77 (float64) + currency = £str.extract, pd.to_numeric
availabilityIn stock (22 available)Número dentro de uma frasein_stock = 22 (Int64)str.extract
ratingThreePalavra, não número3 (Int64)map
titleShakespeare’s GlobeMojibakeShakespeare’s Globeencode/decode, unicodedata
categoryDefaultRótulo de preenchimentoUncategorized (category)replace, astype("category")
description… and their affair begins. ...moreSobras da página no texto… and their affair begins.str.removesuffix
scraped_at2026-09-28T14:51:11+00:00Stringdatetime64[us, UTC]pd.to_datetime
upcmesmo código em duas linhasLivro duplicadouma linha, a cópia mais recentedrop_duplicates

"Mojibake" é o nome do texto embaralhado que surge quando bytes são decodificados com a codificação errada, como ’ no lugar de ’.

O que mudou no pandas 3.0?

O pandas 3.0.0 foi lançado em 21 de janeiro de 2026 e a versão atual é a 3.0.6. Três mudanças das notas de lançamento do pandas 3.0 afetam diretamente o código de limpeza:

  • Strings ganham um tipo próprio. Colunas de texto agora são inferidas como str em vez de object, e os valores ausentes nelas são NaN, como nos outros tipos padrão. É por isso que o script seleciona as colunas de texto com select_dtypes(include=["object", "string"]): funciona no pandas 2 e no 3.
  • Copy-on-Write é o único modo. Uma atribuição encadeada como df["price"][0] = 10 não altera mais df; o pandas 3.0.6 só exibe um aviso ChainedAssignmentError. Use df.loc[0, "price"] = 10. O antigo SettingWithCopyWarning deixou de existir, e as chamadas defensivas a .copy() escritas para silenciá-lo não são mais necessárias.
  • Datas convertidas usam microssegundos por padrão. pd.to_datetime sobre strings agora devolve datetime64[us] em vez de nanossegundos, que é o que você vê na saída mais abaixo.

O pandas 3 também exige Python 3.11 ou mais recente. pd.to_numeric só aceita errors="raise" ou errors="coerce" (referência de to_numeric); trechos antigos que passam errors="ignore" vão falhar.

O script completo: scraping de books.toscrape.com e limpeza

Instale as bibliotecas num ambiente virtual. O pyarrow é necessário para o arquivo Parquet:

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

O scraper lê duas páginas do catálogo e duas páginas de categoria (Poetry e Classics), 78 livros no total, com uma pausa de um segundo entre requisições e novas tentativas automáticas em respostas 429 e 5xx. O parsing do HTML segue a abordagem do nosso tutorial de BeautifulSoup. A segunda execução lê books_raw.csv e só repete a limpeza.

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",     # uma categoria
    BASE + "catalogue/category/books/classics_6/index.html",    # outra
]
MAX_PAGES = 2          # páginas de listagem por URL inicial
DELAY = 1.0            # segundos entre requisições
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 com o palpite do Requests, de propósito: veja o passo 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. Remover espaços em toda coluna de texto; strings vazias viram ausentes
    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: consertar o mojibake e normalizar para NFKC
    def fix_text(s):
        if pd.isna(s):
            return s
        try:
            s = s.encode("latin-1").decode("utf-8")   # "£" -> "£", "é" -> "é"
        except (UnicodeEncodeError, UnicodeDecodeError):
            pass                                        # já está correto
        return unicodedata.normalize("NFKC", s)

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

    # 3. Valores ausentes: contar, sinalizar, não inventar
    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. Preços em texto para 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. Outros números escondidos no 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. Datas
    df["scraped_at"] = pd.to_datetime(df["scraped_at"], utc=True, errors="coerce")

    # 7. Categorias: uma grafia por valor, rótulos inúteis unificados
    df["category"] = (df["category"]
                      .replace({"Default": "Uncategorized",
                                "Add a comment": "Uncategorized"})
                      .astype("category"))

    # 8. Duplicatas: mesmo UPC = mesmo livro; manter a cópia mais recente
    report["duplicate_rows"] = int(df.duplicated("upc").sum())
    df = (df.sort_values("scraped_at")
            .drop_duplicates("upc", keep="last")
            .reset_index(drop=True))

    # 9. Outliers: sinalizar, não apagar
    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())

A primeira execução levou cerca de dois minutos, quase tudo por causa da pausa de cortesia. A linha de relatório que ela imprimiu:

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

Cinco livros de Poetry estavam tanto nas páginas do catálogo quanto na página de Poetry, então 78 linhas viraram 73. Um título de Classics, Alice in Wonderland, não tem bloco de descrição na sua página. Sete livros tinham a categoria "Default". Os preços iam de 12,84 a 58,63, então a regra de outliers não sinalizou nada, o que é a resposta correta para este site. A saída de dtypes mostra str para texto, float64 para preço, Int64 para contagens, category para categoria e datetime64[us, UTC] para o timestamp.

O que cada passo faz e por quê

Texto e Unicode

str.strip() remove os espaços e quebras de linha que o layout HTML deixa em volta dos valores. Transformar "" em valor ausente importa mais do que parece: isna() não conta strings vazias, então sem isso um campo em branco passa por preenchido.

O reparo de codificação desfaz o palpite errado. s.encode("latin-1").decode("utf-8") pega os caracteres que o Requests produziu, transforma-os de volta nos bytes originais e decodifica esses bytes como UTF-8. Uma string que já estava correta passa sem alteração (se for ASCII puro) ou gera erro numa das duas chamadas e fica como está. A correção mais limpa está na origem: passe response.content para o BeautifulSoup ou defina response.encoding = "utf-8" antes de ler response.text. Ainda assim, vale conhecer o reparo para arquivos que você já salvou. Depois dele, unicodedata.normalize("NFKC", s) converte caracteres de compatibilidade, como ligaduras e espaços não separáveis, nas suas formas simples (documentação do unicodedata), para que duas grafias do mesmo título sejam comparadas como iguais.

Valores ausentes

O script conta os valores ausentes por coluna e adiciona um indicador has_description. Ele não escreve "Sem descrição" na lacuna, porque essa string depois pareceria texto real. No caso de números, preencher lacunas com média ou mediana é tarefa da etapa de análise, onde o analista pode deixar isso claro no gráfico, e não da etapa de limpeza.

Números, avaliações e datas

pd.to_numeric(..., errors="coerce") transforma em NaN tudo o que não consegue converter, em vez de parar. É o comportamento certo para uma etapa de limpeza, desde que uma verificação de validação depois conte esses NaN. O tipo anulável Int64 (com I maiúsculo) mantém números inteiros como inteiros mesmo quando falta um valor; o int64 comum não comporta NaN e viraria float sem aviso. As datas são convertidas com utc=True para que timestamps de execuções e máquinas diferentes sejam comparados corretamente.

Categorias e duplicatas

astype("category") guarda cada rótulo uma vez e um código pequeno por linha. Além da economia de memória, isso torna a lista de rótulos explícita: df["category"].cat.categories mostra num relance se "Poetry" e "poetry " sobreviveram as duas.

Para as duplicatas, escolha uma chave que identifique a coisa, não a linha. Aqui é o UPC impresso em cada página de livro. A URL também funcionaria neste site, mas em lojas reais o mesmo produto muitas vezes tem várias URLs (parâmetros de rastreamento, caminhos de categoria). Ordenar por scraped_at antes de drop_duplicates(keep="last") mantém a cópia mais recente, que é o que você quer quando os preços mudam entre execuções.

Outliers e validação

A regra do intervalo interquartil (IQR) sinaliza valores que ficam mais de 1,5 vez o IQR abaixo do primeiro quartil ou acima do terceiro. É fácil de explicar e não pressupõe distribuição normal. O script adiciona uma coluna price_outlier em vez de apagar linhas: um preço de 0.99 numa loja real pode ser uma promoção genuína ou um erro de parsing que leu "0.99" em "10.99". Uma pessoa deve olhar antes de qualquer coisa ser apagada.

validate() é a última linha de defesa. Quando trocamos um preço bruto por N/A e uma avaliação por Six, a execução parou com Validation failed: prices that did not parse, ratings missing or outside 1-5 em vez de gravar um arquivo ruim. Mantenha essas verificações próximas das regras reais dos seus dados; uma verificação que nunca pode falhar não protege nada.

Onde dados de scraping limpos são usados

Erros comuns

  • Limpar a única cópia. Sobrescrever o arquivo bruto faz com que cada mudança de regra exija um novo scraping.
  • Deduplicar primeiro. drop_duplicates compara valores exatos, então "Olio" e "Olio " sobrevivem os dois se você rodar antes de remover os espaços.
  • astype(float) em preços como texto. Falha no primeiro £. Remova os caracteres não numéricos, use pd.to_numeric com errors="coerce" e conte os NaN resultantes.
  • Preencher lacunas com zero. Uma contagem de estoque ausente não é estoque zero. Use Int64 e deixe o valor ausente.
  • Atribuição encadeada. No pandas 3, df[df.price > 50]["flag"] = True não muda nada e só gera um aviso. Use df.loc[df.price > 50, "flag"] = True.
  • Salvar para o Excel sem BOM. O Excel no Windows lê um CSV UTF-8 simples com uma página de código legada e o £ quebra de novo; encoding="utf-8-sig" evita isso.
  • Fazer mais scraping em vez de limpar melhor. Se faltam dados porque requisições falharam, conserte a coleta: respeite o robots.txt, desacelere diante de um HTTP 429 e prefira uma API oficial quando houver.

Guia de decisão

NecessidadeRecomendação
Números guardados como textoRemova os símbolos, pd.to_numeric(errors="coerce") e conte os NaN
Inteiros com lacunasInt64 anulável, não int64 nem float
Texto com £, ’ ou éCorrija a decodificação na origem; conserte arquivos salvos com a ida e volta por latin-1
Mesmo item vindo de várias páginasdrop_duplicates por um ID estável, mantendo a cópia mais recente
Alguns preços estranhosSinalize com a regra do IQR e revise; não apague automaticamente
Rótulos repetidos, como categoriasastype("category") e um mapeamento explícito para os de preenchimento
Timestamps de execuções diferentespd.to_datetime(..., utc=True)
Manter os tipos entre etapasSalve Parquet ao lado do CSV
Scraping grande ou recorrenteLimites de taxa, novas tentativas e, quando o volume exigir, Proxies rotativos via pr.proxynet.io:8000

Perguntas frequentes

Devo limpar os dados durante o scraping ou depois?

Faça o mínimo durante o scraping (pegue o texto e remova os espaços) e o resto depois, no pandas. Guardar o arquivo bruto permite mudar as regras de limpeza e reaplicá-las em segundos, sem enviar uma única requisição nova ao site.

Por que minha coluna de preços mostra £ em vez de £?

O servidor não declarou um charset, então o Requests decodificou bytes UTF-8 como ISO-8859-1. Passe response.content para o seu parser ou defina response.encoding = "utf-8". Para dados já salvos, s.encode("latin-1").decode("utf-8") desfaz o erro.

dropna() é o jeito certo de lidar com valores ausentes?

Só quando uma linha sem aquele campo não serve para a sua pergunta. dropna() sem argumentos apaga a linha se qualquer coluna estiver ausente, o que pode eliminar a maior parte de um conjunto de dados de scraping por causa de um único campo opcional. Use dropna(subset=[...]) para as colunas de que você realmente precisa e sinalize as demais.

Como escolho a coluna para remover duplicatas?

Use um identificador impresso na página, como UPC, SKU, ISBN ou ID de produto. URLs só funcionam em sites onde cada item tem exatamente uma URL. Se não houver ID, combine vários campos normalizados, por exemplo título e marca.

Este código funciona com o pandas 2?

Nós o testamos só no pandas 3.0.6, mas todas as chamadas que ele usa também existem no pandas 2.x, e a seleção de colunas de texto cobre tanto o antigo tipo object quanto o novo tipo str. Espere pequenas diferenças na saída impressa, como object em vez de str e timestamps em nanossegundos em vez de microssegundos. Código que depende de atribuição encadeada é o principal ponto que se comporta de forma diferente entre as duas versões.

Preciso de um proxy para limpar dados?

Não. A limpeza roda na sua máquina e não gera tráfego. Um proxy só importa para a coleta, e só num volume em que um único endereço IP ultrapassaria os limites de taxa de um site. O código de limpeza continua o mesmo, seja qual for a rede pela qual as páginas chegaram.

Em resumo

Dados de scraping chegam como texto formatado para leitores humanos. Uma etapa de limpeza confiável guarda o arquivo bruto, corrige texto e codificação primeiro, converte cada coluna para um tipo real, remove duplicatas por uma chave estável, sinaliza em vez de apagar valores estranhos e para diante de uma verificação que falhou antes de salvar. O script acima faz tudo isso com 78 linhas reais em menos de um segundo depois que as páginas são baixadas. Quando sua coleta crescer além de um site de prática, os planos da nossa página de proxy cuidam da parte de rede.