Raspagem de PDFs com Python: Da Descoberta de Links às Tabelas Extraídas
Web Data Collection Specialist
TL;DR:
- Raspar um PDF começa na página HTML que o liga. Uma página de índice ao vivo gerou quatro URLs de documentos antes que qualquer análise começasse, e essa etapa de descoberta é a parte que a maioria dos guias ignora.
- Um PDF tem que chegar como bytes. Roteando o mesmo documento através de um endpoint que retorna texto produziu 235,403 bytes contra um arquivo real de 140,815, e nem pypdf nem pdfplumber conseguiram ler uma página do resultado.
- Abrir um arquivo não é lê-lo. Construir um leitor sobre esses bytes corrompidos não levantou nada; a falha apareceu apenas quando uma página foi acessada.
- pdfplumber fornece a geometria da página, pypdf fornece a estrutura do documento. Uma página ao vivo retornou 5,659 caracteres de texto, 4 tabelas detectadas e 933 palavras posicionadas.
- Nem toda tabela detectada é dados. Essas quatro tabelas tinham 1, 7, 2 e 10 colunas, e a maioria delas é uma estrutura de layout ao invés de registros.
- Comece de graça. A busca de descoberta de links funciona no nível gratuito da API Universal Scraping.
Instituições públicas publicam seus dados mais úteis como PDFs. Orçamentos, declarações, estatísticas e resultados de inspeção chegam como documentos projetados para impressão, e um raspador que para no HTML nunca vê nada disso.
Este guia começa em uma página que liga quatro PDFs, baixa um e extrai texto, tabelas e posições de palavras dele — depois mede exatamente o que acontece quando o arquivo toma o caminho errado para chegar lá.
O que é um PDF, para fins de raspagem
Um PDF é um contêiner binário que descreve onde as marcas vão em uma página. Ele não tem noção de parágrafo, título ou tabela — apenas glifos em coordenadas, um modelo de descrição de página catalogado em a descrição de formato da Biblioteca do Congresso para a família PDF. Seu tipo de mídia, registrado em a especificação do tipo de mídia application/pdf, é binário precisamente porque o formato não é texto.
Esse único fato impulsiona tudo abaixo. Extrair "o texto" significa reconstruir a ordem de leitura a partir das posições, e extrair "uma tabela" significa inferir linhas e colunas a partir de linhas de contorno e alinhamento. Duas bibliotecas dividem o trabalho:
- pypdf lê a estrutura do documento — contagem de páginas, metadados, estado de criptografia e texto em nível de página.
- pdfplumber lê a geometria da página — caracteres com coordenadas, linhas de contorno detectadas e tabelas construídas a partir delas.
Instalar
bash
pip install pdfplumber pypdf beautifulsoup4
pdfplumber integra pdfminer.six, que faz a análise de baixo nível. Nada aqui precisa de um pacote de sistema ou de um navegador sem interface gráfica.
Etapa 1: Descobrir os Links
Os documentos são referenciados a partir de páginas comuns, então o primeiro passo é o trabalho em HTML. Busque a página de índice e colete cada .pdf href, resolvido em URLs absolutas:
python
import urllib.parse
from bs4 import BeautifulSoup
def pdf_links(html, base_url):
soup = BeautifulSoup(html, "html.parser")
return sorted({
urllib.parse.urljoin(base_url, a["href"])
for a in soup.select('a[href$=".pdf"]')
})
urljoin importa porque esses hrefs geralmente são relativos ao site — a página em teste retorna /pub/irs-pdf/fw9.pdf, que não pode ser acessada sozinha. O set remove duplicatas de um documento vinculado mais de uma vez, algo que páginas de índice fazem constantemente.
Em uma execução ao vivo que retornou quatro documentos:
text
https://www.irs.gov/pub/irs-pdf/fw9.pdf
https://www.irs.gov/pub/irs-pdf/iw9.pdf
https://www.irs.gov/pub/irs-pdf/p1281.pdf
https://www.irs.gov/pub/irs-pdf/p5027.pdf
A própria página de índice é buscada através da API Universal Scraping, que retorna HTML renderizado como uma string — exatamente certo para uma página, e exatamente errado para os documentos que ela liga, como a próxima etapa mostra.
Etapa 2: Baixar como Bytes
python
import urllib.request
BROWSER_UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36")
def fetch_bytes(url):
"""Fetch a binary file. PDFs must arrive as bytes, never as text."""
request = urllib.request.Request(url, headers={"User-Agent": BROWSER_UA})
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
response.read() sem .decode() é todo o ponto. O arquivo baixado tinha 140,815 bytes começando com o cabeçalho %PDF-, que é a assinatura de cinco bytes com a qual todo documento válido começa — uma afirmação simples que vale a pena fazer antes de analisar.
Etapa 3: Ler a Estrutura do Documento
python
import io
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(raw))
print(len(reader.pages), reader.is_encrypted)
io.BytesIO evita escrever um arquivo temporário. O documento ao vivo relatou 6 páginas e encrypted=False. A criptografia vale a pena verificar cedo: um documento criptografado abre bem e gera texto vazio, que se parece idêntico a um documento que simplesmente não tem camada de texto.
Etapa 4: Extrair o Texto
python
import pdfplumber
with pdfplumber.open(io.BytesIO(raw)) as pdf:
page = pdf.pages[0]
text = page.extract_text() or ""
words = page.extract_words()
A página um retornou 5,659 caracteres, começando na linha 'W-9', e 933 palavras posicionadas.
O or "" não é um preenchimento defensivo. extract_text() retorna None quando uma página não possui camada de texto — uma imagem escaneada, tipicamente — e esse None falhará de outra forma em algum lugar longe de sua causa. Trate-o como um sinal de que a página precisa de uma ferramenta diferente, não como uma string vazia.
extract_words() é o que torna o pdfplumber uma dependência valiosa. Cada palavra retorna com x0, x1, top e bottom coordenadas, então quando a ordem de leitura é bagunçada por um layout de múltiplas colunas, você pode selecionar por posição em vez de esperar que o fluxo de texto faça sentido.
Etapa 5: Extraia as Tabelas
python
tables = page.extract_tables()
for index, table in enumerate(tables, 1):
widest = max(len(row) for row in table)
print(f"table {index}: {len(table)} rows x {widest} cols")
A página ao vivo produziu quatro tabelas:
text
table 1: 4 rows x 1 cols
table 2: 2 rows x 7 cols
table 3: 1 rows x 2 cols
table 4: 2 rows x 10 cols
Uma tabela de uma coluna e uma tabela de uma linha não são conjuntos de dados. Elas são regiões delimitadas do layout do formulário que satisfazem a mesma heurística de linha de regra que uma tabela real. Esta é a parte que vale a pena internalizar: extract_tables() relata estruturas retangulares que detectou, e decidir quais delas contêm registros é seu trabalho. Filtre pela forma antes de confiar em qualquer coisa — um mínimo plausível é duas linhas e duas colunas, restringido ao que seu documento realmente usa.
Começar não precisa de cartão — o plano gratuito cobre a busca de descoberta.
O que Acontece Quando um PDF Viaja como Texto
A regra declarada na etapa 2 merece evidências em vez de afirmações, então o mesmo documento foi buscado de uma segunda forma — através do ponto de extremidade que retorna texto usado para a página de índice — e o resultado medido:
text
same pdf through the text endpoint: 235403 bytes (real file is 140815)
pypdf reads it: failed (PdfReadError)
pdfplumber reads it: failed (PdfminerException)
O arquivo cresceu 67%. Decodificar bytes arbitrários como texto e re-encodificá-los expande qualquer coisa fora da faixa ASCII em sequências de múltiplos bytes, o mecanismo descrito em a especificação de codificação UTF-8, e bytes que nunca foram texto válido são substituídos diretamente. O resultado ainda começa com %PDF-, que é a razão pela qual essa falha é convincente o suficiente para desperdiçar uma tarde com isso.
Um detalhe importa mais do que a contagem de bytes. Construir um PdfReader sobre esses dados não levanta nada — o objeto é criado, e apenas tocar reader.pages falha. Uma verificação que para em "abriu" relata sucesso em um arquivo que não pode ser lido, então verifique lendo uma página:
python
def page_count(data):
return len(PdfReader(io.BytesIO(data)).pages)
Use uma API que retorna texto para HTML e uma busca que retorna bytes para documentos. Os dois não são intercambiáveis, e o modo de falha é silencioso.
Todo o Pipeline
python
import io
import json
import logging
import os
import urllib.parse
import urllib.request
import pdfplumber
from bs4 import BeautifulSoup
from pypdf import PdfReader
logging.getLogger("pypdf").setLevel(logging.CRITICAL)
logging.getLogger("pdfminer").setLevel(logging.CRITICAL)
UNLOCKER = "https://api.scrapeless.com/api/v2/unlocker/request"
INDEX = "https://www.irs.gov/forms-pubs/about-form-w-9"
BROWSER_UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36")
def fetch_html(url):
"""Fetch a rendered HTML page as text."""
payload = json.dumps({
"actor": "unlocker.webunlocker",
"input": {"url": url, "proxy_country": "US", "js_render": False},
}).encode()
request = urllib.request.Request(
UNLOCKER, data=payload,
headers={"Content-Type": "application/json",
"x-api-token": os.environ["SCRAPELESS_API_KEY"]},
)
with urllib.request.urlopen(request, timeout=120) as response:
return json.loads(response.read().decode())["data"]
def fetch_bytes(url):
"""Fetch a binary file. PDFs must arrive as bytes, never as text."""
request = urllib.request.Request(url, headers={"User-Agent": BROWSER_UA})
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
def pdf_links(html, base_url):
soup = BeautifulSoup(html, "html.parser")
return sorted({
urllib.parse.urljoin(base_url, a["href"])
for a in soup.select('a[href$=".pdf"]')
})
def main():
html = fetch_html(INDEX)
links = pdf_links(html, INDEX)
print(f"index page: {len(html)} chars")
print(f"pdf links discovered: {len(links)}")
for link in links:
print(f" {link}")
target = next(link for link in links if link.endswith("fw9.pdf"))
raw = fetch_bytes(target)
print(f"downloaded: {len(raw)} bytes, header {raw[:5].decode('latin-1')!r}")
reader = PdfReader(io.BytesIO(raw))
print(f"pypdf: {len(reader.pages)} pages, encrypted={reader.is_encrypted}")
with pdfplumber.open(io.BytesIO(raw)) as pdf:
page = pdf.pages[0]
text = page.extract_text() or ""
print(f"pdfplumber page 1: {len(text)} chars of text")
print(f" first line: {text.splitlines()[0][:60]!r}")
tables = page.extract_tables()
print(f"tables on page 1: {len(tables)}")
for index, table in enumerate(tables, 1):
widest = max(len(row) for row in table)
print(f" table {index}: {len(table)} rows x {widest} cols")
print(f"positioned words on page 1: {len(page.extract_words())}")
# A PDF is binary. Prove what happens if it travels as text.
as_text = fetch_html(target).encode("utf-8")
print(f"same pdf through the text endpoint: {len(as_text)} bytes "
f"(real file is {len(raw)})")
# Open AND read a page — a lenient constructor is not proof the file is usable.
def read_with_pypdf(data):
return len(PdfReader(io.BytesIO(data)).pages)
def read_with_pdfplumber(data):
with pdfplumber.open(io.BytesIO(data)) as opened:
return len(opened.pages)
for name, read in (("pypdf", read_with_pypdf), ("pdfplumber", read_with_pdfplumber)):
try:
print(f" {name} reads it: {read(as_text)} pages")
except Exception as exc:
print(f" {name} reads it: failed ({type(exc).__name__})")
if __name__ == "__main__":
main()
Sua saída:
text
index page: 99970 chars
pdf links discovered: 4
https://www.irs.gov/pub/irs-pdf/fw9.pdf
https://www.irs.gov/pub/irs-pdf/iw9.pdf
https://www.irs.gov/pub/irs-pdf/p1281.pdf
https://www.irs.gov/pub/irs-pdf/p5027.pdf
downloaded: 140815 bytes, header '%PDF-'
pypdf: 6 pages, encrypted=False
pdfplumber page 1: 5659 chars of text
first line: 'W-9'
tables on page 1: 4
table 1: 4 rows x 1 cols
table 2: 2 rows x 7 cols
table 3: 1 rows x 2 cols
table 4: 2 rows x 10 cols
positioned words on page 1: 933
same pdf through the text endpoint: 235403 bytes (real file is 140815)
pypdf reads it: failed (PdfReadError)
pdfplumber reads it: failed (PdfminerException)
As duas linhas logging no topo valem a pena serem mantidas. Ambas as bibliotecas emitem avisos de recuperação em entradas danificadas, e no documento corrompido, aquele ruído entope a única linha que importa.
Resolução de Problemas
extract_text() retorna None ou uma string vazia. A página não tem camada de texto, o que quase sempre significa que é uma imagem digitalizada. Nenhuma quantidade de configuração do parser recupera texto que nunca foi codificado; esse trabalho precisa de reconhecimento óptico de caracteres, que é um pipeline diferente com características de precisão diferentes.
O texto sai intercalado entre as colunas. O fluxo de texto segue a ordem em que os glifos foram escritos, não a ordem de leitura. Use extract_words() e agrupe pela coordenada top, ou recorte a página com page.crop((x0, top, x1, bottom)) e extraia cada coluna separadamente.
extract_tables() não encontra nada em uma página que claramente tem uma tabela. A estratégia padrão busca linhas de regra. Uma tabela separada apenas por espaço em branco precisa de table_settings={"vertical_strategy": "text", "horizontal_strategy": "text"}, que infere colunas a partir do alinhamento em vez disso.
As células contêm None. Células mescladas e vazias retornam como None em vez de "". Normalize antes de escrever em qualquer lugar, e trate uma linha que é principalmente None como um artefato de detecção em vez de um registro.
Todo documento é analisado, mas os números estão errados. Verifique se o arquivo foi buscado como bytes. Um arquivo corrompido em trânsito ainda pode carregar o cabeçalho %PDF- e ainda construir um objeto leitor, então compare o comprimento baixado com o Content-Length que o servidor relatou — o campo definido em a especificação de semântica HTTP.
Conclusão
A parte do PDF da raspagem de PDF é a parte fácil. Duas bibliotecas cobrem isso: pypdf para estrutura, pdfplumber para qualquer coisa que dependa de onde as coisas estão na página.
As partes que dão errado estão em ambos os lados. Encontrar os documentos é trabalho em HTML, e obtê-los intactos é uma questão de transporte com um modo de falha silencioso — um arquivo que chega 67% maior, ainda começa com %PDF-, ainda constrói um leitor e não pode ser lido. Afirme contando os bytes e leia uma página antes de acreditar em qualquer coisa.
Pronto para direcionar isso para seus próprios documentos? Crie uma conta gratuita no Scrapeless, exporte sua chave e execute a fase de descoberta contra uma página que você já coleta. Os limites de plano estão na página de preços. Se você só precisa de texto bruto a partir de formatos de documento mistos em vez da geometria da página, o guia de documento para Markdown cobre um caminho mais leve.
FAQ
Q: Devo usar pdfplumber ou pypdf?
Use pypdf quando você precisar de fatos em nível de documento — contagem de páginas, metadados, estado de criptografia, fusão ou separação de arquivos — e pdfplumber quando você precisar saber onde as coisas estão na página, o que cobre extração de tabelas e qualquer layout de várias colunas. Eles coexistem felizmente; o pipeline neste post usa ambos, e pdfplumber é a dependência mais pesada porque carrega um mecanismo de layout completo.
Q: Por que meu download de PDF teve sucesso, mas falhou ao analisar?
Na maioria das vezes, foi decodificado como texto em algum lugar durante o trânsito. Busque com response.read() e sem .decode(), depois verifique duas coisas: se os primeiros cinco bytes são %PDF- e se o comprimento corresponde ao que o servidor anunciou. Uma viagem de ida e volta de texto inflaciona o arquivo — 140,815 bytes se tornaram 235,403 na medição aqui — enquanto deixa o cabeçalho intacto.
Q: Posso extrair tabelas de um PDF escaneado?
Não com essas bibliotecas. Um escaneamento é uma imagem, e ambas as ferramentas leem o texto e as camadas vetoriais que um documento carrega. extract_text() retornando None é o sinal. Recuperar esse conteúdo requer OCR, e a saída deve ser tratada como uma estimativa a ser validada em vez de como dados extraídos.
Q: Como eu encontro os links para PDF quando eles não são âncoras simples?
Amplie o seletor antes de recorrer a um navegador: muitos sites vinculam documentos através de um caminho de redirecionamento sem o sufixo .pdf, então combine com um padrão de URL ou com o texto do link em vez da extensão. Se os links forem genuinamente escritos por JavaScript do lado do cliente, a página de índice precisa ser renderizada — mas verifique o HTML inicial primeiro, pois os URLs frequentemente já estão lá.
Q: É extract_tables() confiável o suficiente para produção?
Para documentos com tabelas demarcadas e um layout estável, sim, desde que você valide a forma do que retornar. A página ao vivo aqui retornou quatro tabelas, das quais a maioria eram regiões de layout, então um filtro em mínimo de linhas e colunas não é opcional. Quando as tabelas de um documento são definidas apenas por espaços em branco, mude para a estratégia baseada em texto e verifique os resultados novamente em uma página que você já leu.
Q: Como eu devo lidar com documentos muito grandes?
Itere pdf.pages em vez de materializar tudo, e feche o documento com o gerenciador de contexto como os exemplos fazem. Se você só precisa de parte de um arquivo, page.crop() limita o trabalho a uma região, e pypdf pode dividir um grande documento em menores antes que o trabalho de geometria dispendioso comece.
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.



