Volver al blog

Raspado de PDFs con Python: Desde el Descubrimiento de Enlaces hasta Tablas Extraídas

Isabella Garcia
Isabella Garcia

Web Data Collection Specialist

06-Aug-2026

TL;DR:

  • Raspar un PDF comienza en la página HTML que lo enlaza. Una página de índice en vivo arrojó cuatro URLs de documentos antes de que comenzara cualquier análisis, y ese paso de descubrimiento es la parte que la mayoría de las guías omiten.
  • Un PDF debe llegar como bytes. Dirigir el mismo documento a través de un endpoint que devuelve texto produjo 235,403 bytes contra un archivo real de 140,815, y ni pypdf ni pdfplumber pudieron leer una página del resultado.
  • Abrir un archivo no es leerlo. Construir un lector sobre esos bytes corruptos no arrojó nada en absoluto; la falla apareció solo cuando se tocó una página.
  • pdfplumber te da la geometría de la página, pypdf te da la estructura del documento. Una página en vivo devolvió 5,659 caracteres de texto, 4 tablas detectadas y 933 palabras posicionadas.
  • No cada tabla detectada es datos. Esas cuatro tablas tenían 1, 7, 2 y 10 columnas, y la mayoría de ellas son andamiaje de diseño en lugar de registros.
  • Empieza gratis. La búsqueda de descubrimiento de enlaces se ejecuta en el nivel gratuito de la API de Raspeo Universal.

Las instituciones públicas publican sus datos más útiles como PDFs. Los presupuestos, las declaraciones, las estadísticas y los resultados de inspección llegan como documentos diseñados para imprimir, y un raspador que se detiene en el HTML nunca ve nada de eso.

Esta guía comienza en una página que enlaza cuatro PDFs, descarga uno, y extrae texto, tablas y posiciones de palabras — luego mide exactamente qué sucede cuando el archivo toma la ruta equivocada para llegar allí.

Qué es un PDF, para Propósitos de Raspeo

Un PDF es un contenedor binario que describe dónde van las marcas en una página. No tiene noción de un párrafo, un encabezado, o una tabla — solo glifos en coordenadas, un modelo de descripción de página catalogado en la descripción de formato de la Biblioteca del Congreso para la familia PDF. Su tipo de medio, registrado en la especificación del tipo de medio application/pdf, es binario precisamente porque el formato no es texto.

Ese hecho único impulsa todo lo que sigue. Extraer "el texto" significa reconstruir el orden de lectura a partir de posiciones, y extraer "una tabla" significa inferir filas y columnas a partir de líneas de reglas y alineación. Dos bibliotecas dividen el trabajo:

  • pypdf lee la estructura del documento — recuento de páginas, metadatos, estado de cifrado, y texto a nivel de página.
  • pdfplumber lee la geometría de la página — caracteres con coordenadas, líneas de reglas detectadas, y tablas construidas a partir de ellas.

Instalar

bash Copy
pip install pdfplumber pypdf beautifulsoup4

pdfplumber incorpora pdfminer.six, que realiza el análisis de bajo nivel. Nada de esto necesita un paquete del sistema o un navegador sin cabeza.

Etapa 1: Descubrir los Enlaces

Los documentos se referencian desde páginas ordinarias, así que el primer paso es trabajo en HTML. Obtén la página de índice y recoge cada .pdf href, resuelto a 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 es importante porque estos hrefs son generalmente relativos al sitio — la página bajo prueba devuelve /pub/irs-pdf/fw9.pdf, que no se puede obtener por sí sola. El set elimina duplicados de un documento enlazado más de una vez, lo que las páginas de índice hacen constantemente.

En una ejecución en vivo que devolvió cuatro 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

La propia página de índice se obtiene a través de la API de Raspeo Universal, que devuelve HTML renderizado como una cadena — exactamente apropiado para una página, y exactamente incorrecto para los documentos que enlaza, como muestra la siguiente etapa.

Etapa 2: Descargar 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() sin .decode() es el objetivo principal. El archivo descargado tenía 140,815 bytes comenzando con el encabezado %PDF-, que es la firma de cinco bytes con la que comienza cada documento válido — una afirmación barata que vale la pena hacer antes del análisis.

Etapa 3: Leer la Estructura del Documento

python Copy
import io

from pypdf import PdfReader

reader = PdfReader(io.BytesIO(raw))
print(len(reader.pages), reader.is_encrypted)

io.BytesIO evita escribir un archivo temporal. El documento en vivo informó 6 páginas y encrypted=False. El cifrado es importante de verificar temprano: un documento cifrado se abre bien y produce texto vacío, que se ve idéntico a un documento que simplemente no tiene capa de texto.

Etapa 4: Extraer el 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()

La primera página devolvió 5,659 caracteres, comenzando en la línea 'W-9', y 933 palabras posicionadas.

El or "" no es un relleno defensivo. extract_text() devuelve None cuando una página no tiene capa de texto — una imagen escaneada, típicamente — y ese None fallará en otro lugar, lejos de su causa. Trátalo como una señal de que la página necesita una herramienta diferente, no como una cadena vacía.
extract_words() es lo que hace que pdfplumber valga la pena como dependencia. Cada palabra regresa con x0, x1, top y bottom coordenadas, así que cuando el orden de lectura se desordena por un diseño de varias columnas, puedes seleccionar por posición en lugar de esperar que el flujo de texto tenga sentido.

Etapa 5: Extraer las Tablas

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")

La página en vivo produjo cuatro tablas:

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

Una tabla de una columna y una tabla de una fila no son conjuntos de datos. Son regiones enmarcadas del diseño del formulario que satisfacen la misma heurística de línea de regla que una tabla real. Esta es la parte que vale la pena internalizar: extract_tables() informa estructuras rectangulares que detectó, y decidir cuáles de ellas contienen registros es tu trabajo. Filtra por forma antes de confiar en cualquier cosa: un mínimo plausible son dos filas y dos columnas, ajustado a lo que tu documento realmente utiliza.

Empezar no necesita tarjeta: el plan gratuito cubre la recuperación de descubrimiento.

Qué Ocurre Cuando un PDF Viaja como Texto

La regla establecida en la etapa 2 merece evidencia en lugar de afirmaciones, así que el mismo documento fue recuperado de una segunda manera — a través del punto final que devuelve texto usado para la página de índice — y el 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)

El archivo creció un 67%. Decodificar bytes arbitrarios como texto y volver a codificarlos expande cualquier cosa fuera del rango ASCII en secuencias de varios bytes, el mecanismo descrito en la especificación de codificación UTF-8, y los bytes que nunca fueron texto válido son reemplazados directamente. El resultado aún comienza con %PDF-, por lo que este fallo es lo suficientemente convincente como para desperdiciar una tarde.

Un detalle importa más que el conteo de bytes. Construir un PdfReader sobre esos datos genera nada — el objeto se crea, y solo tocar reader.pages falla. Una verificación que se detiene en "¿se abrió?" informa éxito en un archivo que no puede ser leído, así que verifica leyendo una página:

python Copy
def page_count(data):
    return len(PdfReader(io.BytesIO(data)).pages)

Usa una API que devuelve texto para HTML y una recuperación que devuelve bytes para documentos. Los dos no son intercambiables, y el modo de fallo es silencioso.

Todo el 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()

Su salida:

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)

Las dos líneas logging en la parte superior valen la pena mantener. Ambas bibliotecas emiten advertencias de recuperación sobre entradas dañadas, y en el documento corrupto ese ruido entierra la única línea que importa.

Solución de Problemas

extract_text() devuelve None o una cadena vacía. La página no tiene capa de texto, lo que casi siempre significa que es una imagen escaneada. Ninguna cantidad de configuración del analizador recupera texto que nunca fue codificado; ese trabajo necesita reconocimiento óptico de caracteres, que es un pipeline diferente con diferentes características de precisión.

El texto aparece entrelazado entre columnas. El flujo de texto sigue el orden en que se escribieron los glifos, no el orden de lectura. Usa extract_words() y agrupa por la top coordenada, o recorta la página con page.crop((x0, top, x1, bottom)) y extrae cada columna por separado.

extract_tables() no encuentra nada en una página que claramente tiene una tabla. La estrategia predeterminada busca líneas de regla. Una tabla separada solo por espacios en blanco necesita table_settings={"vertical_strategy": "text", "horizontal_strategy": "text"}, que infiere columnas a partir de la alineación en su lugar.

Las celdas contienen None. Las celdas combinadas y vacías regresan como None en lugar de "". Normaliza antes de escribir en cualquier lugar, y trata una fila que es mayormente None como un artefacto de detección en lugar de un registro.

Cada documento se analiza, pero los números son incorrectos. Verifica si el archivo fue recuperado como bytes. Un archivo dañado en tránsito aún puede contener la cabecera %PDF- y aún construir un objeto lector, así que compara la longitud descargada con la Content-Length que el servidor informó — el campo definido en la especificación de semántica HTTP.

Conclusión

La parte de PDF del raspado de PDF es la parte fácil. Dos bibliotecas la cubren: pypdf para estructura, pdfplumber para cualquier cosa que dependa de dónde se sitúan las cosas en la página.

Las partes que salen mal están a ambos lados. Encontrar los documentos es trabajo HTML, y obtenerlos intactos es una cuestión de transporte con un modo de fallo silencioso — un archivo que llega un 67% más grande, aún comienza con %PDF-, aún construye un lector, y no puede ser leído. Asegúrate del conteo de bytes y lee una página antes de creer algo de ello.
¿Listo para apuntar esto a tus propios documentos? Crea una cuenta gratuita de Scrapeless, exporta tu clave y ejecuta la etapa de descubrimiento contra una página que ya coleccionas. Los límites de plan están en la página de precios. Si solo necesitas texto plano de formatos de documentos mixtos en lugar de geometría de página, la guía de documento a Markdown cubre un camino más ligero.

FAQ

P: ¿Debería usar pdfplumber o pypdf?

Usa pypdf cuando necesites hechos a nivel de documento: conteo de páginas, metadatos, estado de cifrado, fusión o división de archivos, y pdfplumber cuando necesites saber dónde están las cosas en la página, lo cual cubre la extracción de tablas y cualquier diseño de múltiples columnas. Coexisten felizmente; la canalización en esta publicación utiliza ambos, y pdfplumber es la dependencia más pesada porque lleva un motor de diseño completo.

P: ¿Por qué mi descarga de PDF tuvo éxito pero falló al analizar?

Con mayor frecuencia fue decodificado como texto en algún lugar en tránsito. Obtén con response.read() y sin .decode(), luego verifica dos cosas: que los primeros cinco bytes sean %PDF- y que la longitud coincida con lo que el servidor anunció. Un viaje de ida y vuelta de texto infla el archivo — 140,815 bytes se convirtieron en 235,403 en la medición aquí — mientras deja el encabezado intacto.

P: ¿Puedo extraer tablas de un PDF escaneado?

No con estas bibliotecas. Un escaneo es una imagen, y ambas herramientas leen las capas de texto y vectores que lleva un documento. extract_text() devolviendo None es la señal. Recuperar ese contenido requiere OCR, y la salida debe ser tratada como una estimación a validar en lugar de como datos extraídos.

P: ¿Cómo encuentro los enlaces PDF cuando no son anclajes simples?

Amplía el selector antes de buscar en un navegador: muchos sitios enlazan documentos a través de un camino de redirección sin sufijo .pdf, así que empareja en un patrón de URL o en el texto del enlace en lugar de la extensión. Si los enlaces son genuinamente escritos por JavaScript del lado del cliente, la página de índice necesita renderizarse — pero revisa el HTML inicial primero, porque las URL frecuentemente ya están allí.

P: ¿Es extract_tables() lo suficientemente confiable para producción?

Para documentos con tablas reguladas y un diseño estable, sí, siempre que valides la forma de lo que regresa. La página en vivo aquí devolvió cuatro tablas de las cuales la mayoría eran regiones de diseño, por lo que un filtro en filas y columnas mínimas no es opcional. Cuando las tablas de un documento están definidas solo por espacios en blanco, cambia a la estrategia basada en texto y vuelve a verificar los resultados contra una página que hayas leído tú mismo.

P: ¿Cómo debo manejar documentos muy grandes?

Itera pdf.pages en lugar de materializar todo, y cierra el documento con el administrador de contexto como lo hacen los ejemplos. Si solo necesitas parte de un archivo, page.crop() limita el trabajo a una región, y pypdf puede dividir un documento grande en archivos más pequeños antes de que comience el costoso trabajo de geometría.

En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.

Artículos más populares

Catalogar