A nossa posição: somos a Geonode e vendemos proxies, pelo que as tabelas na Web pública estão diretamente relacionadas com a nossa atividade. É importante ter em conta que read_html ao aceder diretamente a um URL não lhe dá qualquer controlo sobre o pedido — sem cabeçalhos personalizados, sem sessão, sem lógica de repetição de tentativas e sem qualquer forma de o encaminhar através de qualquer outro meio. Para uma tabela pontual num site colaborativo, isso é suficiente e é a opção mais rápida disponível. Para qualquer coisa recorrente, recupere o HTML por conta própria com um cliente HTTP adequado e passe a string para read_html. Essa separação custa apenas uma linha extra e oferece-lhe tudo o que a função fetch integrada não disponibiliza.
Noções básicas
import pandas as pd
tables = pd.read_html("https://example.com/data")
print(len(tables))
df = tables[0]
O pormenor essencial, segundo a documentação do pandas: devolve «uma lista de DataFrames». Não um único DataFrame. Uma página com seis tabelas fornece-lhe seis, pela ordem em que aparecem no documento, e tables[0]
pode muito bem ser um layout de navegação e não os dados que pretendia.
A documentação também deixa claro que «sempre devolverá uma lista de DataFrames ou falhará completamente» — não devolverá uma lista vazia, exceto em casos invulgares, como «uma única linha com um <td>
contendo apenas espaços em branco». Portanto, um resultado vazio é um sinal de que algo de estranho aconteceu, em vez de ser um resultado normal.
Também pode passar HTML diretamente, o que é a forma a preferir para qualquer coisa que vá além de uma análise rápida:
import requests
html = requests.get(url, headers={"User-Agent": "MyBot/1.0 (+https://example.com/bot)"}).text
tables = pd.read_html(html)
O que pode e o que não pode ver
Compreender o âmbito evita a maior parte das desilusões.
Lê apenas elementos <table>. A documentação indica que «procura elementos <table> e processa apenas <tr>, <th> e elementos <td> dentro de tabelas». Um layout construído a partir de elementos <div> com estilo de grelha — o que é o design web mais moderno — não contém nenhuma tabela que possa ser encontrada, independentemente de quão semelhante a uma tabela pareça no ecrã.
Não executa JavaScript. Se a tabela for renderizada do lado do cliente, o HTML que o read_html vê não a contém. Esta é a razão mais comum para a mensagem «não encontrou tabelas» numa página que, visivelmente, tem uma.
Lida corretamente com o atributo spanning. Os atributos colspan e rowspan «são tratados corretamente», o que é mais do que muitos analisadores criados manualmente conseguem fazer.
Dá preferência a <thead> para cabeçalhos e, caso não exista, recorre à procura no corpo do documento.
Respeita display: none por predefinição. A configuração predefinida «displayed_only=True» «exclui elementos com display: none», o que normalmente é o desejado e, ocasionalmente, oculta dados que um site disponibilizou deliberadamente apenas para determinadas janelas de visualização.
Duas notas sobre dependências. Os motores de análise são, em primeiro lugar, lxml, recorrendo em segundo lugar a bs4 e html5lib — a documentação refere que «'bs4' e 'html5lib' são sinónimos» como nomes de variantes. E há uma peculiaridade das URLs que vale a pena conhecer: «O lxml só aceita os protocolos de URL http, ftp e file. Se tiver uma URL que comece por 'https', pode tentar remover o 's'.» Na prática, carregar a página por si próprio evita completamente este problema.
Os parâmetros que importam
A assinatura completa tem dezoito parâmetros. Seis deles fazem a maior parte do trabalho.
**match
** (por predefinição '.+'
) filtra as tabelas cujo texto corresponde a uma expressão regular. Este é o parâmetro mais útil de todos e é subutilizado:
tables = pd.read_html(html, match="Population")
Em vez de adivinhar um índice numa lista, basta indicar algo que a tabela contenha. É muito mais robusto quando o layout de uma página muda, uma vez que o conteúdo geralmente sobrevive a uma reformulação que altera a posição da tabela.
**attrs
** filtra por atributos HTML, o que é a outra forma de identificar uma tabela específica:
tables = pd.read_html(html, attrs={"id": "results", "class": "data"})
**header
** especifica o nome da linha de cabeçalho. A documentação destaca um pormenor de ordenação que confunde as pessoas: «o argumento header
é aplicado depois de skiprows
ser aplicado». Assim, se saltar duas linhas e depois solicitar header=0
, obtém a primeira linha após o salto.
**skiprows
** remove linhas antes da análise — útil para tabelas com linhas de título acima do cabeçalho real.
**index_col
** define uma coluna como índice.
**thousands
** (padrão ','
) e **decimal
** (padrão '.'
) tratam da formatação numérica. Isto é mais importante do que parece: uma tabela europeia que utilize 1.234,56
necessita de thousands='.'
e decimal=','
; sem elas, todos os números transformam-se silenciosamente numa cadeia de caracteres ou num valor errado.
Mais duas opções que vale a pena conhecer:
**converters
** aplica uma função por coluna no momento da análise, o que é mais elegante do que corrigir os tipos posteriormente.
**extract_links
** captura o href
dos links dentro das células, em vez de apenas o seu texto — o que é verdadeiramente útil quando as linhas da tabela têm links para páginas de detalhes que também pretende.
A limpeza que ninguém evita
A documentação define as expectativas de forma honesta, e vale a pena ler o aviso antes de ter de descobrir por si próprio:
Esteja preparado para fazer alguma limpeza depois de chamar esta função. Por exemplo, poderá ter de atribuir manualmente nomes às colunas se estes forem convertidos em NaN ao passar o argumento ``header=0`
`.
E:
Tentamos fazer o mínimo de suposições possível sobre a estrutura da tabela e transferimos as peculiaridades do HTML contido na tabela para o utilizador.
Essa segunda frase é a filosofia de design expressa de forma clara. read_html
fornece-lhe o que o HTML contém; organizá-lo é tarefa sua.
A limpeza que surge sempre:
Colunas MultiIndex provenientes de cabeçalhos que se estendem por várias linhas. Uma tabela com um cabeçalho de duas linhas produz um MultiIndex
, o que está correto, mas é pouco prático. Simplifique-a:
df.columns = [" ".join(str(c) for c in col).strip() for col in df.columns]
Espaços em branco e espaços não separáveis. O HTML está repleto de
, que se transformam em \xa0
e anulam um simples .strip()
:
df = df.replace("\xa0", " ", regex=True)
df.columns = df.columns.str.replace("\xa0", " ", regex=False).str.strip()
Números que são cadeias de caracteres. Símbolos monetários, sinais de percentagem e marcadores de notas de rodapé:
df["Price"] = (df["Price"].astype(str)
.str.replace(r"[^\d.,-]", "", regex=True)
.str.replace(",", "")
.pipe(pd.to_numeric, errors="coerce"))
errors="coerce"
transforma valores impossíveis de analisar em NaN
em vez de gerar um erro, o que permite contar quantos falharam em vez de perder toda a operação por causa de uma única célula incorreta.
Linhas de notas de rodapé e totais. Muitas tabelas terminam com uma linha de resumo que não contém dados. Filtre-a explicitamente, em vez de assumir que a última linha é segura.
Selecionar a tabela certa
Quatro abordagens, por ordem crescente de robustez.
Por índice — tables[0]
. Adequado para exploração, mas frágil num script. Uma nova tabela adicionada acima da sua provoca uma falha silenciosa, porque o índice 0 ainda existe e agora contém outra coisa.
**Por match
** — a melhor opção por predefinição. Indique uma cadeia de caracteres que apareça na tabela pretendida e em mais lado nenhum.
**Por attrs
** — ideal quando a tabela tem um id ou uma classe distintiva, uma vez que estes são escolhidos deliberadamente por um programador.
Por forma, após o carregamento — quando nada mais a identifica:
candidates = [t for t in pd.read_html(html)
if {"Name", "Price"}.issubset(t.columns)]
if len(candidates) != 1:
raise ValueError(f"expected 1 matching table, found {len(candidates)}")
df = candidates[0]
Essa verificação é a parte importante. Um script que seleciona silenciosamente a primeira de três correspondências irá produzir dados errados durante meses. Falhar quando a contagem é inesperada transforma um problema de dados numa mensagem de erro.
Ler várias páginas para um único DataFrame
O próximo passo natural, depois de ter uma única tabela a funcionar, e o ponto em que alguns hábitos evitam verdadeiros problemas.
Concatenar em vez de anexar num ciclo. Construir um DataFrame de forma incremental é lento e produz um índice fragmentado. Recolha os frames e combine-os de uma só vez:
frames = []
for page in range(1, 11):
df = fetch_table(f"https://example.com/data?page={page}",
match="Population",
expected_columns=["Country", "Population"])
df["source_page"] = page
frames.append(df)
combined = pd.concat(frames, ignore_index=True)
Registe a origem de cada linha. A coluna «source_page
» acima não custa nada e responde à pergunta que, mais cedo ou mais tarde, lhe será feita — de que página provém este valor anómalo. Para tudo o que for recolhido ao longo do tempo, adicione também um carimbo de data/hora. Um conjunto de dados sem proveniência é muito difícil de depurar e impossível de auditar.
Controle o ritmo do ciclo. Dez páginas obtidas tão rapidamente quanto a ligação permite constituem um pico de tráfego que, do ponto de vista do servidor, pode parecer um ataque. Uma pausa de um segundo entre os pedidos é uma atitude educada e, na maioria dos sites, a diferença entre concluir a tarefa e ser sujeito a limitação de taxa:
import time, random
time.sleep(1 + random.random())
**Lide com as falhas página a página, em vez de abandonar a execução.**Uma página cujo layout tenha mudado não deve fazer com que perca as outras nove:
frames, failures = [], []
for page in range(1, 11):
try:
frames.append(fetch_table(url_for(page), match="Population", expected_columns=COLS))
except Exception as exc:
failures.append((page, str(exc)))
if failures:
print(f"{len(failures)} pages failed:", failures)
Verifique se as estruturas correspondem antes de concatenar. Se a página sete tiver uma coluna que as outras não têm, pd.concat
irá alegremente produzir um quadro cheio de NaN
para as linhas incompatíveis — válido, bem formado e errado. Comparar os conjuntos de colunas antes de as combinar transforma isso num erro que se consegue ver.
E elimine as duplicatas depois. As tabelas paginadas repetem frequentemente linhas entre páginas, especialmente quando os dados subjacentes mudam a meio do rastreio. combined.drop_duplicates()
num subconjunto significativo de colunas é uma medida de proteção simples para evitar contar o mesmo registo duas vezes.
Quando utilizar outra ferramenta O `
`read_html`` é um wrapper de conveniência. Existem quatro situações em que é necessário recorrer a uma ferramenta diferente.
Quando os dados não estão num <table>. Layouts de cartões, listas de definições, grelhas <div>. Utilize um analisador verdadeiro — lxml ou BeautifulSoup com seletores CSS ou XPath — e crie o DataFrame por si próprio:
from lxml import html as lh
tree = lh.fromstring(page)
rows = [{"name": c.cssselect("h3")[0].text_content().strip(),
"price": c.cssselect(".price")[0].text_content().strip()}
for c in tree.cssselect("div.product-card")]
df = pd.DataFrame(rows)
Quando a página necessita de JavaScript. Renderize-a primeiro com o Playwright ou uma ferramenta semelhante e, em seguida, passe o HTML renderizado para read_html. Essa combinação funciona bem e é frequentemente o caminho mais curto:
html = page.content() # rendered DOM, not source
tables = pd.read_html(html)
Quando precisar de controlar o pedido. Cabeçalhos, cookies, sessões, novas tentativas, tempos de espera, proxies — nada disso é exposto pelo read_html quando faz a recuperação por si. Recupere separadamente e passe a cadeia de caracteres.
Quando o site oferece dados estruturados. Um ficheiro CSV para descarregar, uma API ou JSON-LD incorporado na página. Qualquer uma destas opções é melhor do que analisar o HTML renderizado em termos de estabilidade, e vale a pena dedicar trinta segundos a verificar antes de escrever qualquer coisa.
Como fazê-lo corretamente num script
O padrão para tudo o que é executado mais do que uma vez.
import pandas as pd
import requests
HEADERS = {"User-Agent": "AcmeDataBot/1.0 (+https://acme.example.com/bot)"}
def fetch_table(url, match, expected_columns):
resp = requests.get(url, headers=HEADERS, timeout=30)
resp.raise_for_status()
tables = pd.read_html(resp.text, match=match)
if len(tables) != 1:
raise ValueError(f"{url}: expected 1 table matching {match!r}, got {len(tables)}")
df = tables[0]
df.columns = [str(c).replace("\xa0", " ").strip() for c in df.columns]
missing = set(expected_columns) - set(df.columns)
if missing:
raise ValueError(f"{url}: missing columns {missing}; got {list(df.columns)}")
if df.empty:
raise ValueError(f"{url}: table matched but contains no rows")
return df
Há cinco aspetos aqui que fazem a diferença entre um script que falha de forma evidente e outro que falha discretamente.
**raise_for_status()
** deteta erros HTTP, porque read_html
numa página de erro não encontrará tabelas ou encontrará as tabelas erradas.
**match
em vez de um índice**, para que uma alteração no layout produza um erro em vez de apresentar a tabela errada.
Verificar exatamente uma correspondência, para que um resultado ambíguo seja interrompido em vez de aceitar silenciosamente o primeiro.
Verificar as colunas esperadas, o que deteta uma reformulação que as renomeia ou reordena — a falha que, de outra forma, produziria indefinidamente dados incorretos, embora bem formados.
Verificar se está vazio, uma vez que uma tabela correspondente sem linhas é quase sempre um sintoma, em vez de um resultado.
Um agente de utilizador honesto com um URL de contacto não custa nada e torna-o num cliente que um operador pode optar por permitir, em vez de um que tenha de bloquear.
Perguntas frequentes
O que faz o pandas.read_html?
Analisa o código HTML e devolve todos os elementos <table> que encontrar como uma lista de DataFrames. Lida com colspan e rowspan, utiliza <thead> para cabeçalhos, quando presentes, e, por predefinição, exclui elementos ocultos com display: none.
Por que razão o read_html devolve uma lista?
Porque uma página pode conter qualquer número de tabelas, e o pandas devolve todas elas pela ordem em que aparecem no documento. Devolve sempre uma lista ou falha — não devolve uma lista vazia, exceto em casos invulgares — pelo que um resultado vazio é, por si só, um sinal de que algo correu mal.
Por que é que o read_html diz «Nenhuma tabela encontrada»?
Ou a página não tem elementos <table> — os layouts modernos utilizam frequentemente elementos <div> com estilo em vez disso — ou a tabela é renderizada por JavaScript que o pandas não executa. Verifique o código-fonte HTML bruto, em vez da visualização renderizada pelo navegador, para determinar qual das situações se verifica.
Como seleciono uma tabela específica?
Utilize match com uma expressão regular que corresponda ao texto dentro da tabela pretendida, ou attrs para filtrar por um id ou classe. Ambas as opções são muito mais robustas do que indexar na lista, o que falha silenciosamente quando uma tabela é adicionada acima da sua.
O read_html funciona com páginas renderizadas em JavaScript?
Não. Ele analisa HTML e não executa scripts. Renderize primeiro a página com uma ferramenta de automação de navegador e, em seguida, passe a cadeia de caracteres HTML resultante para read_html — essa combinação funciona bem e é normalmente o caminho mais curto.
Como posso limpar o DataFrame depois?
Deve contar com a necessidade de achatar colunas MultiIndex provenientes de cabeçalhos que se estendem por várias colunas, remover espaços não separáveis que aparecem como \xa0, converter cadeias de caracteres de moeda e percentagem em números com pd.to_numeric(errors="coerce") e remover linhas de notas de rodapé ou de totais. A documentação do pandas indica explicitamente que é necessário efetuar esta limpeza.
Posso utilizar um proxy ou cabeçalhos personalizados com o read_html?
Não quando o próprio read_html recupera o URL — não disponibiliza opções de pedido. Recupere a página com requests ou outro cliente, onde possa controlar cabeçalhos, tempos de espera, sessões e proxies, e depois passe a cadeia de caracteres HTML para read_html.
Para que servem os parâmetros «thousands» e «decimal»?
Indicam ao pandas como os números devem ser formatados, sendo os valores por predefinição, respetivamente, ',' e '.'. Para a formatação europeia, como 1.234,56, é necessário utilizar thousands='.' e decimal=',' — sem eles, os valores são analisados incorretamente sem aviso prévio ou mantidos como cadeias de caracteres.
Conclusão O `
`read_htmlé uma função de conveniência verdadeiramente útil com um âmbito restrito: identifica elementos<table>`` no código HTML que lhe for fornecido e transforma-os em DataFrames. Dentro desse âmbito, lida com as partes mais complicadas — células que se estendem, deteção de cabeçalhos, elementos ocultos — melhor do que a maioria dos analisadores escritos manualmente.
Há dois aspetos a ter em conta: o facto de devolver uma lista em vez de um DataFrame e de a própria documentação indicar que é necessário efetuar uma limpeza. Selecionar por match ou attrs em vez de por índice e verificar se correspondeu exatamente uma tabela transforma a falha silenciosa mais comum numa mensagem de erro.
Para qualquer coisa que seja executada mais do que uma vez, recupere o HTML por conta própria. Uma linha extra dá-lhe cabeçalhos, tempos limite, novas tentativas, sessões e tudo o resto que o fetch integrado não oferece — e, ao mesmo tempo, contorna a peculiaridade do protocolo URL do lxml.
E quando a página não tiver tabelas, pare de procurar parâmetros. Uma grelha do tipo «<div>» ou uma página renderizada pelo cliente é um problema diferente, e a solução passa por um analisador verdadeiro, uma etapa de renderização ou — melhor ainda — os dados estruturados que o site provavelmente já publica algures onde ainda não procurou.
