Como Construir um Pipeline de Web Scraping com Pandas e Scrapeless
Advanced Data Extraction Specialist
TL;DR:
- A API Scrapeless Universal Scraping retorna HTML renderizado para uma página pública, e o
read_htmldo pandas transforma as tabelas dessa página em DataFrames em uma única chamada. - Este guia constrói o trabalho em cinco etapas explícitas: buscar, descobrir, extrair, transformar e armazenar, cada uma com algumas linhas de Python.
- No pandas 3.x,
read_htmlprecisa de um wrapperio.StringIOem torno de uma string HTML, porque uma string simples agora é lida como um caminho de arquivo e gera um erro. - A limpeza deve ser feita após a extração: renomeie as colunas, force tipos numéricos com
pd.to_numerice adicione colunas derivadas antes de qualquer coisa ser escrita no disco. - Parquet preserva tipos de coluna, mas não é automaticamente menor que CSV; em uma amostra de 75 linhas, sua sobrecarga de rodapé torna-o o arquivo maior, e a vantagem de tamanho aparece com volume.
- Comece com o plano gratuito do Scrapeless e aponte a etapa de busca para sua própria fonte.
A maioria dos tutoriais do pandas começa com um CSV que já existe. O trabalho real raramente começa daí. Os números que você deseja estão dentro de uma página HTML, envoltos em navegação, estilização e marcação que um simples requests.get muitas vezes não consegue nem mesmo recuperar de forma limpa. A lacuna entre "existe uma tabela naquela página" e "existe um DataFrame tipado na memória" é onde vive um pipeline de scraping.
Este post fecha essa lacuna com duas ferramentas. A API Scrapeless Universal Scraping cuida da recuperação e retorna a página como HTML. O pandas cuida da estrutura: ele lê as tabelas, as limpa e escreve arquivos tipados que você pode analisar. O alvo de exemplo é uma sandbox pública de scraping com uma tabela paginada das temporadas da equipe da NHL, portanto, cada número abaixo vem de uma execução real contra uma página real.
Pipeline em um relance
O pipeline tem cinco etapas, e cada uma entrega um único objeto bem definido à próxima:
buscar (string HTML) → descobrir (qual tabela) → extrair (DataFrame) → transformar (DataFrame tipado e limpo) → armazenar (CSV + Parquet)
Manter as etapas separadas compensa na primeira vez que uma página muda. Quando o layout se altera, apenas a etapa de descoberta se move. Quando uma coluna começa a chegar como texto, apenas a etapa de transformação muda. As etapas de busca e armazenamento permanecem intactas.
Etapa 1: Buscar a página como HTML limpo
A etapa de busca envia uma solicitação ao Scrapeless e retorna a página como uma string HTML. A solicitação leva um actor e um objeto input; o ator unlocker.webunlocker recupera a página, e a chave da API é enviada no cabeçalho x-api-token.
Este pipeline precisa de três pacotes: o próprio pandas, um parser para read_html e um mecanismo Parquet para a etapa de armazenamento. Instale todos os três de uma só vez.
bash
pip install pandas pyarrow lxml
python
import io
import json
import os
import urllib.request
import pandas as pd
API_URL = "https://api.scrapeless.com/api/v2/unlocker/request"
def fetch_html(url: str) -> str:
payload = json.dumps(
{"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "headless": False}}
).encode()
request = urllib.request.Request(
API_URL,
data=payload,
headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"], "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=90) as response:
return json.loads(response.read())["data"]
html = fetch_html("https://www.scrapethissite.com/pages/forms/")
tables = pd.read_html(io.StringIO(html))
df = tables[0]
print(f"tabelas na página: {len(tables)} | formato: {df.shape}")
print("colunas brutas:", list(df.columns))
O corpo da resposta é um envelope JSON com a marcação renderizada sob a chave data, então json.loads(response.read())["data"] é a página inteira como uma string. O js_render é definido como False aqui de propósito, como explica a seção Quando Você Precisa de uma Página Renderizada.
Defina a chave uma vez em seu terminal antes de executar qualquer coisa. Use a chave real em tempo de execução e mantenha o espaço reservado fora de seu código-fonte.
bash
export SCRAPELESS_API_KEY="sk_your_key_here"
Etapa 2: Descobrir a Tabela
A descoberta responde a uma pergunta: qual tabela na página contém os dados. Executar o bloco acima imprime a resposta para este alvo.
text
tabelas na página: 1 | formato: (25, 9)
colunas brutas: ['Nome da Equipe', 'Ano', 'Vitórias', 'Derrotas', 'Derrotas em OT', 'Porcentagem de Vitórias', 'Gols a Favor (GF)', 'Gols Contra (GA)', '+ / -']
read_html escaneia o HTML e retorna uma lista com um DataFrame para cada elemento <table> que encontra, seguindo o mesmo modelo de tabela especificado na especificação de dados tabulares HTML. Esta página possui uma única tabela, então tables[0] é a que você deseja. Em uma página com várias tabelas, imprima a forma e as primeiras linhas de cada uma, escolha o índice que corresponde às suas colunas e codifique esse índice manualmente. Os nomes das colunas brutas vêm diretamente das células <th>, razão pela qual ainda possuem espaços e pontuação.
Etapa 3: Ler em um DataFrame
A extração já foi feita. Esse é o propósito do read_html: transformar toda a tabela em um DataFrame sem um loop manual sobre linhas e células. A referência do pandas read_html documenta a exigência de io.StringIO que causa problemas nas primeiras tentativas no pandas 3.x. Uma string HTML pura é interpretada como um nome de arquivo; envolvê-la em io.StringIO(html) diz ao pandas para analisar a string em si.
Com 25 linhas e 9 colunas em mãos, o DataFrame bruto é utilizável, mas ainda não está limpo. Os nomes das colunas são estranhos, e cada valor ainda é do tipo que o analisador HTML inferiu. Ambos os problemas pertencem à próxima etapa.
Etapa 4: Transformar e Tipar os Dados
A etapa de transformação faz três tarefas em ordem: renomear as colunas para algo que você possa digitar, forçar os valores a números e adicionar as colunas derivadas que sua análise necessita.
python
raw.columns = ["time", "ano", "vitórias", "derrotas", "derrotas_ot", "percent_vitórias", "gols_pró", "gols_contra", "dif_gols"]
numeric = ["ano", "vitórias", "derrotas", "derrotas_ot", "percent_vitórias", "gols_pró", "gols_contra", "dif_gols"]
raw[numeric] = raw[numeric].apply(pd.to_numeric, errors="coerce")
raw["jogos"] = raw["vitórias"] + raw["derrotas"] + raw["derrotas_ot"].fillna(0)
raw["temporada_vencedora"] = raw["percent_vitórias"] >= 0.5
clean = raw.dropna(subset=["time", "vitórias"]).reset_index(drop=True)
pd.to_numeric com errors="coerce" é o principal. Ele converte números limpos e transforma qualquer coisa que não pode ser analisada em NaN em vez de falhar toda a coluna, o que é importante aqui porque as temporadas mais antigas deixam a célula de derrotas na prorrogação em branco. fillna(0) então trata esses espaços em branco como zero ao calcular os jogos jogados, e dropna remove qualquer linha que falte um nome de time ou uma contagem de vitórias. O resultado é um DataFrame em que cada coluna numérica realmente é numérica e as colunas derivadas jogos e temporada_vencedora estão prontas para serem agrupadas e filtradas.
Etapa 5: Armazenar como CSV e Parquet
O armazenamento grava o DataFrame limpo duas vezes, pois os dois formatos atendem necessidades diferentes.
python
clean.to_csv("times.csv", index=False)
clean.to_parquet("times.parquet", index=False)
CSV é portátil e legível por qualquer coisa, desde uma planilha até um comando de linha de comando, e segue o formato de valores separados por vírgula amplamente implementado. Seu custo é que os tipos desaparecem no momento em que você escreve; cada coluna é texto ao ser lida novamente. Parquet mantém o esquema. Quando você lê times.parquet de volta, vitórias ainda é um inteiro e percent_vitórias ainda é um float, sem re-coerção, porque o formato de arquivo Apache Parquet armazena o tipo de cada coluna ao lado de seus valores.
Parquet não é necessariamente o arquivo menor, independentemente do que a folclore diz. Neste exemplo de 75 linhas, o CSV tem 4.379 bytes e o arquivo Parquet tem 8.999 bytes, porque os metadados e o rodapé por coluna do Parquet são uma sobrecarga fixa que um pequeno conjunto de dados não pode amortizar. Armazene resultados pequenos como CSV se o tamanho for tudo o que você se importa. Use Parquet quando a contagem de linhas crescer para dezenas de milhares e as leituras tipadas e colunares começarem a importar mais do que a contagem de bytes.
O Pipeline Completo
Coloque as cinco etapas em um script e adicione paginação, e o pipeline busca três páginas, constrói um DataFrame de 75 linhas, limpa-o e escreve ambos os arquivos.
python
import io
import json
import os
import urllib.request
import pandas as pd
API_URL = "https://api.scrapeless.com/api/v2/unlocker/request"
BASE = "https://www.scrapethissite.com/pages/forms/"
PAGES = 3 # limitado: três páginas da tabela pública de sandbox
def fetch_html(url: str) -> str:
"""Etapa 1 - buscar HTML renderizado através da Scrapeless Universal Scraping API."""
payload = json.dumps(
{"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "headless": False}}
).encode()
request = urllib.request.Request(
API_URL,
data=payload,
headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"], "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=90) as response:
return json.loads(response.read())["data"]
# Estágios 1-3 - buscar cada página, descobrir a tabela única, lê-la diretamente em um DataFrame
frames = []
for page in range(1, PAGES + 1):
html = fetch_html(f"{BASE}?page_num={page}")
frames.append(pd.read_html(io.StringIO(html))[0])
raw = pd.concat(frames, ignore_index=True)
print(f"páginas buscadas: {PAGES} | linhas analisadas: {len(raw)}")
# Estágio 4 - transformar: limpar nomes de colunas, forçar tipos numéricos, adicionar colunas derivadas
raw.columns = ["time", "ano", "vitórias", "derrotas", "derrotas_ot", "pct_vitórias", "gols_a_favor", "gols_contra", "dif_gols"]
numeric = ["ano", "vitórias", "derrotas", "derrotas_ot", "pct_vitórias", "gols_a_favor", "gols_contra", "dif_gols"]
raw[numeric] = raw[numeric].apply(pd.to_numeric, errors="coerce")
raw["jogos"] = raw["vitórias"] + raw["derrotas"] + raw["derrotas_ot"].fillna(0)
raw["temporada_vencedora"] = raw["pct_vitórias"] >= 0.5
clean = raw.dropna(subset=["time", "vitórias"]).reset_index(drop=True)
print(f"linhas após limpeza: {len(clean)} | temporadas vencedoras: {int(clean['temporada_vencedora'].sum())}")
melhor = clean.sort_values("vitórias", ascending=False).iloc[0]
print(f"melhor temporada: {melhor['time']} {int(melhor['ano'])} ({int(melhor['vitórias'])} vitórias)")
# Estágio 5 - armazenar o DataFrame como CSV para portabilidade e Parquet para leituras columnar tipadas
clean.to_csv("times.csv", index=False)
clean.to_parquet("times.parquet", index=False)
print(f"bytes csv: {os.path.getsize('times.csv')} | bytes parquet: {os.path.getsize('times.parquet')}")
A execução imprime um resumo compacto de cada estágio:
text
páginas buscadas: 3 | linhas analisadas: 75
linhas após limpeza: 75 | temporadas vencedoras: 26
melhor temporada: Pittsburgh Penguins 1992 (56 vitórias)
bytes csv: 4379 | bytes parquet: 8999
O parâmetro de consulta page_num controla a paginação, e PAGES limita a execução a três páginas para que o exemplo permaneça pequeno e educado. Aumente essa constante para ampliar a cobertura e mantenha-a em um único local para que o limite seja uma decisão, não um acidente.
Aponte esse pipeline para uma fonte que você se importa trocando a URL BASE e os nomes das colunas, e a estrutura dos cinco estágios se mantém inalterada. Se você quiser um contexto mais amplo sobre onde cada um desses estágios se encaixa, o guia sobre o que é um pipeline ETL aborda extração, transformação e carregamento como um padrão geral.
Quando Você Precisa de uma Página Renderizada
Este pipeline define js_render como False, e essa é uma escolha deliberada, não um padrão a ser deixado de lado. A tabela sandbox está presente no HTML que o servidor envia, então não há nada para um navegador renderizar, e pular a renderização torna cada busca mais rápida. Muitas páginas são diferentes: a tabela que você deseja é injetada por JavaScript após o carregamento inicial do HTML, e uma busca não renderizada retorna um shell vazio. Quando read_html não encontra nenhuma tabela em uma página que você pode ver em um navegador, defina js_render como True para que o Scrapeless retorne a página após a execução de seus scripts. Decida por fonte em vez de ativar a renderização em todos os lugares, porque renderizar uma página que não precisa disso apenas adiciona latência.
Antes de escalar qualquer uma dessas operações, leia o robots.txt e os termos do alvo. O Protocolo de Exclusão de Robôs informa quais caminhos um site pede para que clientes automatizados fiquem sem acessar, e respeitá-lo mantém um pipeline de dados do lado certo dos sites dos quais depende. Mantenha o volume limitado e o alvo público, como faz este exemplo.
Pronto para executar isso contra uma fonte real? Crie uma conta gratuita no Scrapeless e mude a URL na etapa de busca.
Conclusão
Um pipeline de raspagem é composto por cinco pequenos estágios, cada um cumprindo uma função específica. O Scrapeless busca a página, read_html extrai a tabela, to_numeric e algumas atribuições a limpam, e duas chamadas to_ armazenam. Como os estágios são separados, o pipeline sobrevive a mudanças: um novo layout afeta a descoberta, um novo tipo de coluna afeta a transformação, e o restante permanece. Comece com o script funcionando acima, troque pelo seu próprio alvo e amplie a etapa de transformação à medida que suas necessidades de dados demandem.
Comece com o plano gratuito do Scrapeless para executar a etapa de busca em suas próprias páginas e verifique o preço do Scrapeless quando dimensionar um trabalho recorrente.
FAQ
P: Por que pandas.read_html falha em uma string HTML no pandas 3.x?
Um argumento de string nua é tratado como um caminho de arquivo ou URL, então o pandas tenta abri-lo e gera um erro. Envolva a marcação em io.StringIO(html) e passe isso em vez disso; read_html então analisa a string na memória e retorna uma lista de DataFrames.
P: Eu preciso do BeautifulSoup ou lxml para usar read_html?
read_html precisa de um parser HTML instalado, e usa lxml ou html5lib por trás dos panos, então instale um deles junto com o pandas. Você não escreve o código do parser para extração de tabelas; read_html dirige o parser e devolve DataFrames.
Q: Quando devo definir js_render como True?
Defina como True quando os dados são adicionados à página pelo JavaScript após o carregamento inicial do HTML, o que resulta em read_html encontrar zero tabelas em uma página que claramente tem uma no navegador. Deixe como False quando a tabela já estiver no HTML do servidor, pois renderizar uma página desnecessária apenas adiciona latência.
Q: Devo armazenar dados raspados como CSV ou Parquet?
Escolha CSV quando você quiser um arquivo portátil e legível por humanos e a contagem de linhas for pequena; escolha Parquet quando você quiser preservar tipos de coluna e o conjunto de dados for grande o suficiente para que leituras tipadas e colunares importem. Em amostras pequenas, Parquet pode ser o arquivo maior devido ao seu overhead fixo de rodapé, então apenas o tamanho favorece CSV até que os dados cresçam.
Q: Como devo lidar com uma página com várias tabelas?
read_html retorna cada tabela como um DataFrame separado em uma lista, então imprima a forma e as primeiras linhas de cada elemento para identificar o que você deseja, depois indexe-o diretamente. Uma vez que você saiba que a posição é estável, codifique esse índice na fase de extração.
Q: Como mantenho a raspagem educada quando adiciono mais páginas?
Mantenha a página vinculada em uma única constante, como PAGES faz aqui, para que ampliar a cobertura seja uma edição deliberada em vez de um loop sem limites. Leia o robots.txt do site e os termos primeiro, e colete apenas dados públicos em um volume que o alvo possa absorver.
Na Scorretless, acessamos apenas dados disponíveis ao público, enquanto cumprem estritamente as leis, regulamentos e políticas de privacidade do site aplicáveis. O conteúdo deste blog é apenas para fins de demonstração e não envolve atividades ilegais ou infratoras. Não temos garantias e negamos toda a responsabilidade pelo uso de informações deste blog ou links de terceiros. Antes de se envolver em qualquer atividade de raspagem, consulte seu consultor jurídico e revise os termos de serviço do site de destino ou obtenha as permissões necessárias.



