DEV Community

fecklesspleb
fecklesspleb

Posted on

Descarga todas las imágenes de una página web con Python (incluidos srcset, carga diferida y og:image)

Este artículo es una traducción al español, hecha con ayuda de IA, de mi post original en inglés: Download all images from a web page with Python (srcset, lazy loading and og:image included). El código, los comandos y los números se cotejaron con el original y con el repositorio antes de publicar.

"Descargar todas las imágenes de esta página" suena a una sola línea de código: obtener el HTML, encontrar cada <img src> y descargar cada una. En una página real, eso te deja una carpeta de miniaturas pequeñas, un puñado de marcadores de posición (placeholders) grises de carga diferida, algunos píxeles de seguimiento y, posiblemente, una página de error HTML guardada como photo.jpg. Las versiones grandes están en srcset, las URL reales están en data-src y la mejor imagen de la página muchas veces solo aparece en una etiqueta <meta property="og:image">.

Escribí una pequeña herramienta en Python que maneja esos casos usando solo la biblioteca estándar: fecklesspleb/page-image-downloader (MIT, un solo archivo, sin navegador). Este post recorre lo que tiene que resolver y luego muestra lo que devolvió una ejecución real.

1. Usa el parser HTML de Python, no una expresión regular

html.parser.HTMLParser está en la biblioteca estándar y maneja el orden de los atributos, las comillas y las entidades (&amp; en la cadena de consulta de una URL es común en los CDN de imágenes). La herramienta hereda de esa clase y registra, en el orden de la página, cada lugar donde puede esconderse una imagen:

  • <img src>, más data-src, data-lazy-src y data-original, que los scripts de carga diferida usan para la URL real mientras src contiene un marcador de posición. Cuando existen ambos, gana el atributo de carga diferida.
  • <img srcset> y <picture><source srcset>, más data-srcset / data-lazy-srcset.
  • <meta property="og:image"> y <meta name="twitter:image">, la imagen que muestran las redes sociales cuando se comparte la página.
  • Opcionalmente, enlaces <a href> que terminan en las extensiones que indiques, como .pdf.

Cada URL se resuelve contra <base href> (si existe) y la URL de la página con urllib.parse.urljoin, se eliminan los fragmentos (#fragments) y cada URL se conserva una sola vez por página.

2. Analiza srcset correctamente y quédate con la más grande

Un srcset lista la misma imagen en varios tamaños:

<img src="photo-400.jpg"
     srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w">
Enter fullscreen mode Exit fullscreen mode

Dividir por comas falla con las URL de CDN que contienen comas (/image/upload/w_800,q_80/photo.jpg). La regla de la especificación HTML es más simple y segura: una URL llega hasta el primer espacio en blanco y luego el descriptor llega hasta la siguiente coma. La herramienta lo analiza así y se queda con el candidato de mayor w, o con la mayor densidad x cuando no hay anchos:

def best_srcset_url(value):
    best, best_key = None, None
    for url, desc in parse_srcset(value):
        key = (0, 1.0)                      # no descriptor means 1x
        m = re.match(r"^(\d+)w$", desc)
        if m:
            key = (1, float(m.group(1)))    # any width beats any density
        else:
            m = re.match(r"^(\d+(?:\.\d+)?)x$", desc)
            if m:
                key = (0, float(m.group(1)))
        if best_key is None or key > best_key:
            best, best_key = url, key
    return best
Enter fullscreen mode Exit fullscreen mode

(Los comentarios del código dicen "sin descriptor equivale a 1x" y "cualquier ancho le gana a cualquier densidad".)

Las miniaturas de Wikipedia incluyen una versión 2x en srcset, así que para las fotos del artículo esto significa un archivo de 500 px en lugar del de 250 px que está en src.

3. Revisa los bytes, no la URL

Una URL que termina en .jpg puede devolver una página 404, un muro de inicio de sesión o un WebP. Por eso la herramienta mira los primeros bytes de cada descarga: \x89PNG, \xff\xd8\xff (JPEG), GIF8, RIFF....WEBP, ftypavif, BM, la cabecera ICO o <svg. Cualquier otra cosa se registra como not_an_image y no se guarda, y la extensión del archivo sale de los bytes, no de la URL.

La misma cabecera da el tamaño sin decodificar nada. En un PNG, el ancho y el alto son dos enteros big-endian justo después de IHDR:

if data.startswith(b"\x89PNG\r\n\x1a\n") and data[12:16] == b"IHDR":
    width, height = struct.unpack(">II", data[16:24])
Enter fullscreen mode Exit fullscreen mode

JPEG requiere un pequeño bucle por los segmentos hasta el marcador de inicio de frame (SOF), y WebP tiene tres variantes (con pérdida, sin pérdida y extendida) con estructuras diferentes; la herramienta maneja PNG, JPEG, GIF, WebP, AVIF, BMP y SVG. Con el ancho conocido, --min-width 100 descarta íconos y espaciadores, y --min-kb 2 descarta píxeles de seguimiento.

Una trampa: un SVG con width="34ex" no tiene tamaño en píxeles. Una versión temprana de mi herramienta recurría al viewBox y reportaba una fórmula matemática con 5664 px de ancho. Ahora cualquier unidad que no sea píxeles deja el ancho y el alto vacíos, y --min-width conserva esos archivos en lugar de adivinar.

4. Omite duplicados por hash

El mismo logo suele aparecer en la cabecera y en el pie de página con dos URL distintas. De cada archivo se calcula el hash SHA-256; un hash que ya se guardó en la ejecución se registra como duplicate, apuntando a la primera copia. El nombre del archivo incluye los primeros 10 caracteres hexadecimales del hash (logo-3f9a0c12be.png), así que dos archivos distintos llamados logo.png nunca se sobrescriben.

5. Sé respetuoso

  • urllib.robotparser revisa robots.txt para cada página y cada imagen antes de solicitarla, con un User-Agent que nombra la herramienta y enlaza al repositorio. Si no hay robots.txt (4xx), se permite todo; si da error (5xx), se bloquea el host, como exige el RFC 9309.
  • Las solicitudes al mismo host se separan 0,5 s por defecto.
  • HTTP 429 y 5xx se reintentan hasta tres veces, respetando Retry-After; un 404 no se reintenta.

Una ejecución real

El 3 de noviembre de 2026 la ejecuté contra la página principal de python.org, el artículo "Lighthouse" de la Wikipedia en inglés y una URL directa de un SVG en upload.wikimedia.org:

python3 page_image_downloader.py -i urls.txt -o images --min-kb 2 --min-width 100
Enter fullscreen mode Exit fullscreen mode
Entrada Candidatos Guardados Omitidos
Página principal de python.org 2 2 0
Wikipedia "Lighthouse" 41 27 7 de menos de 100 px de ancho, 4 de menos de 2 KB, 3 bloqueados por robots.txt
SVG directo 1 1 0

En total, 30 archivos y 1 799 209 bytes en 19,5 segundos, sin errores.

Algunas cosas llamaron la atención:

  • python.org tiene dos imágenes en su HTML: el <img> del logo y el og:image. Revisé el HTML crudo con grep y tiene exactamente una etiqueta <img>. Una página puede parecer llena de imágenes mientras sus imágenes vienen de CSS o de scripts, que esta herramienta (a propósito) no ejecuta.
  • srcset hizo la mayor parte del trabajo. 22 de los 30 archivos guardados vinieron de srcset. En Wikipedia, 40 etiquetas <img>, 2 etiquetas <source> y el og:image sumaron 43 referencias, que quedaron en 41 URL únicas.
  • La imagen para compartir fue el archivo más grande. El og:image de Wikipedia era una foto aérea de 960 × 1415 (326 760 bytes); el cuerpo del artículo solo enlaza una versión de 500 px. Un scraper que solo mire <img> nunca vería la grande.
  • robots.txt bloqueó tres solicitudes, todas en en.wikipedia.org, que prohíbe /w/ y /wiki/Special:: dos insignias del pie de página de MediaWiki y una imagen de Special:CentralAutoLogin. La herramienta nunca las solicitó.
  • El filtro de ancho importó más que el de tamaño: 7 archivos tenían menos de 100 px de ancho y 4 pesaban menos de 2 KB.

Para verificar el manifiesto abrí los 25 archivos raster con Pillow: cada ancho y alto coincidió con lo que la herramienta leyó de las cabeceras, y cada SHA-256 coincidió con el archivo en disco.

Una segunda ejecución con --links pdf contra la página de información del RFC 9309 en rfc-editor.org guardó 4 archivos: el og:image de la página, dos logos SVG y el RFC como un PDF de 177 688 bytes. La herramienta comprueba que un "PDF" no sea en realidad una página HTML (un muro de inicio de sesión, por ejemplo) antes de guardarlo.

Lo que no hace

  • No ejecuta JavaScript. Las imágenes que un script agrega después de cargar la página no se encuentran. Las páginas renderizadas en el servidor, los atributos de carga diferida y srcset funcionan bien; para aplicaciones de una sola página necesitas un navegador sin interfaz gráfica (headless).
  • No toma imágenes de fondo de CSS ni <video poster>.
  • Una solicitud a la vez, lo cual está bien para decenas de páginas.

Descarga solo imágenes que tengas derecho a usar, y deja activada la pausa entre solicitudes.

Pruébala

git clone https://github.com/fecklesspleb/page-image-downloader
cd page-image-downloader
python3 page_image_downloader.py https://en.wikipedia.org/wiki/Lighthouse --min-width 100
Enter fullscreen mode Exit fullscreen mode

Obtienes images/en.wikipedia.org/ con los archivos y un images/manifest.csv que lista cada candidato con su estado, tamaño, ancho, alto, texto alternativo y hash. Las 37 pruebas se ejecutan sin conexión contra un servidor de prueba local: python3 -m unittest discover -s tests -v.

El código, las pruebas y la lista completa de estados están en el repositorio: fecklesspleb/page-image-downloader.


Sobre el uso de IA: este artículo se tradujo del inglés con ayuda de IA; el artículo original y el código también se escribieron con ayuda de IA. Los números provienen de ejecuciones reales del 3 de noviembre de 2026. El código, los comandos y los números se cotejaron con el original y con el repositorio antes de publicar. Sin afiliación con la Python Software Foundation, Wikipedia, la Wikimedia Foundation ni el RFC Editor.

Top comments (0)