🎯 Um navegador em nuvem personalizável e anti-detecção alimentado por Chromium desenvolvido internamente, projetado para rastreadores web e agentes de IA. 👉Experimente agora
De volta ao blog

Raspagem de PDFs com Python: Da Descoberta de Links às Tabelas Extraídas

Isabella Garcia
Isabella Garcia

Web Data Collection Specialist

06-Aug-2026

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 Copy
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.

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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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.

Artigos mais populares

Catálogo