Cómo Hacer Scraping de Imágenes de un Sitio Web: Lazy Loading, srcset y la Trampa del Logo
Apuntas tu scraper a un anuncio inmobiliario para recoger las fotos del piso. El script se ejecuta sin errores y devuelve tres URL de imagen: el logo de la agencia, una foto del agente y un GIF gris de 1x1. Las doce fotos del piso que viste en tu navegador no aparecen por ningún lado.
Nadie te ha bloqueado. La página cargó bien. El problema es que <img src="..."> es el lugar menos fiable para buscar una imagen en un sitio web moderno. Las URL reales están en otros atributos, en datos estructurados, en JSON, o solo aparecen después de que se ejecuta JavaScript. Y la página está llena de imágenes que no quieres.
Esta guía explica dónde se esconden realmente las URL de las imágenes, cómo elegir las correctas y cómo descargarlas sin recibir HTML o un 403 a cambio.
Por Qué img src Te Miente
Un scraper de imágenes ingenuo hace soup.find_all("img") y lee src. Eso falla por cinco motivos habituales:
- Lazy loading. Muchos sitios ponen un marcador de posición en
src(undata:image/gifen base64, un spinner oblank.gif) y guardan la URL real endata-src,data-lazy-src,data-originalodata-srcset. Un script los intercambia cuando la imagen entra en pantalla. Tu cliente HTTP nunca hace scroll. - Imágenes responsive.
srcsetlista varios tamaños de la misma imagen, y<picture>añade elementos<source>por formato. Elsrcde respaldo suele ser la versión más pequeña. - Galerías con JavaScript. Un carrusel puede renderizar solo la primera diapositiva en el HTML. El resto de las fotos llega como JSON y se inyecta después.
- Fondos CSS. Algunas galerías usan
<div style="background-image: url(...)">en lugar de<img>. - Ruido. Logos, avatares, fotos de agentes, iconos, insignias, creatividades publicitarias, píxeles de seguimiento y miniaturas de “anuncios similares” también son etiquetas
<img>. En la página de un anuncio pueden superar en número a las fotos que buscas.
La solución es una lista de comprobación corta, en este orden.
Paso 1: Revisa Primero los Datos Estructurados
Antes de analizar un solo <img>, busca imágenes que el sitio ya describe en un formato legible por máquinas. Suelen ser las imágenes principales del contenido, a tamaño completo, y más limpias que cualquier cosa del diseño.
- JSON-LD. Las páginas de producto, anuncio y artículo suelen incluir un bloque
<script type="application/ld+json">con un campoimage, a veces con un array completo de URL de la galería. - Open Graph.
<meta property="og:image">es la imagen que se usa en las vistas previas de enlaces. Normalmente es la foto principal, pero en algunos sitios es el logo de la marca, así que verifícalo. - Estado del framework. Next.js y frameworks similares incrustan los datos de la página en
__NEXT_DATA__o en un objetowindow.__INITIAL_STATE__. Las URL de la galería suelen estar ahí completas.
import json
import requests
from bs4 import BeautifulSoup
html = requests.get(url, headers={"User-Agent": UA}, timeout=20).text
soup = BeautifulSoup(html, "html.parser")
def jsonld_images(soup):
found = []
for tag in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(tag.string or "")
except json.JSONDecodeError:
continue
for item in data if isinstance(data, list) else [data]:
img = item.get("image") if isinstance(item, dict) else None
if isinstance(img, str):
found.append(img)
elif isinstance(img, list):
found += [i if isinstance(i, str) else i.get("url") for i in img]
elif isinstance(img, dict):
found.append(img.get("url"))
return [u for u in found if u]
Si la galería se carga desde una petición aparte, abre DevTools, filtra la pestaña Network por Fetch/XHR y busca una respuesta llena de URL de imágenes. Explicamos esa técnica en detalle en cómo encontrar la API JSON oculta detrás de un sitio web con JavaScript.
Paso 2: Limítate a la Galería, No a Toda la Página
Esta es la solución a la trampa del logo. No recojas todas las imágenes de la página para filtrarlas después. Encuentra el elemento que envuelve la galería de fotos y busca solo dentro de él.
Abre la página en tu navegador, haz clic derecho sobre una foto de la galería, elige Inspeccionar y sube por el DOM hasta llegar al contenedor: algo como un div con una clase que contenga gallery, carousel, slider o media, o un elemento con itemprop="image". Úsalo como raíz.
gallery = soup.select_one("[class*=gallery], [class*=carousel], [data-testid*=gallery]")
root = gallery or soup.select_one("main") or soup
imgs = root.find_all("img")
Los selectores amplios de arriba son un punto de partida, no una regla. Cada sitio nombra las cosas de forma distinta, así que fija el selector en el contenedor real en cuanto lo encuentres. La ficha del agente, el logo de la cabecera y la franja de “anuncios similares” están fuera de la galería, así que desaparecen automáticamente.
Paso 3: Lee Todos los Atributos que Pueden Contener una URL
Dentro de la galería, revisa los atributos de lazy loading antes que src, y elige el candidato más grande de cualquier srcset. Resuelve las URL relativas y las que empiezan sin protocolo (//cdn.example.com/...) a partir de la URL de la página.
import re
from urllib.parse import urljoin
LAZY_ATTRS = ["data-src", "data-lazy-src", "data-original", "data-url", "src"]
SRCSET_ATTRS = ["data-srcset", "srcset"]
def largest_from_srcset(srcset):
best, best_size = None, -1
# split on ", " or right after a descriptor, so CDN URLs with commas survive
for part in re.split(r",\s+|(?<=\d[wx]),", srcset.strip()):
bits = part.strip().split()
if not bits:
continue
size = 1.0
if len(bits) > 1 and bits[1][-1] in "wx":
try:
size = float(bits[1][:-1])
except ValueError:
pass
if size > best_size:
best, best_size = bits[0], size
return best
def image_url(tag, page_url):
for attr in SRCSET_ATTRS:
best = largest_from_srcset(tag.get(attr, ""))
if best:
return urljoin(page_url, best)
for attr in LAZY_ATTRS:
value = tag.get(attr, "")
if value and not value.startswith("data:"):
return urljoin(page_url, value)
return None
urls = [u for u in (image_url(img, url) for img in imgs) if u]
# <picture><source srcset="..."> and CSS background images
for source in root.select("picture source[srcset]"):
urls.append(urljoin(url, largest_from_srcset(source["srcset"])))
for el in root.select("[style*=background-image]"):
m = re.search(r"url\(['\"]?(.*?)['\"]?\)", el["style"])
if m:
urls.append(urljoin(url, m.group(1)))
Ten en cuenta que srcset usa dos tipos de descriptores: ancho (800w) y densidad de píxeles (2x). Ambos funcionan con la regla de “quédate con el número más alto” siempre que un mismo atributo no los mezcle, algo que la especificación HTML tampoco permite.
Paso 4: Filtra el Ruido
Incluso dentro de la galería se te colará algo de basura. Fíltrala en dos pasadas.
Antes de descargar, usando la URL y los atributos:
- Descarta las URI
data:y los archivos.svg(los iconos y logos casi siempre son SVG). - Descarta las URL o nombres de clase que contengan palabras como
logo,avatar,icon,sprite,placeholder,badgeopixel. - Descarta imágenes con atributos
widthoheightdiminutos, por ejemplo menos de 100 píxeles. - Elimina duplicados. La misma foto suele aparecer dos veces: una en la tira de miniaturas y otra como diapositiva principal.
Después de descargar, usando el archivo real:
- Ábrelo con Pillow y descarta todo lo que esté por debajo de un tamaño mínimo, por ejemplo 300 píxeles en el lado corto. Así atrapas los marcadores de posición que se hayan colado.
- Para los casi duplicados (la misma foto con dos tamaños o compresiones), compara hashes perceptuales con la librería
imagehashen lugar de hashes de bytes.
from io import BytesIO
from PIL import Image
import imagehash
def keep(content, seen_hashes, min_side=300):
img = Image.open(BytesIO(content))
if min(img.size) < min_side:
return False
h = imagehash.phash(img)
if any(h - other <= 4 for other in seen_hashes):
return False
seen_hashes.append(h)
return True
Paso 5: Convierte las Miniaturas en Imágenes a Tamaño Completo
A veces el HTML solo contiene miniaturas, y la imagen a tamaño completo se carga cuando el usuario abre el lightbox. Muchos CDN de imágenes codifican el tamaño en la URL, ya sea como parámetro de consulta (?w=320, ?width=320&quality=60) o como segmento de la ruta (/320x240/, _thumb).
No adivines el patrón. Abre el lightbox en tu navegador, cambia el filtro de la pestaña Network a Img y compara la URL de la imagen grande con la miniatura que obtuviste. Normalmente cambia un solo segmento. Cuando conozcas la regla de ese sitio, aplícala a todas las URL de miniaturas. Si el CDN rechaza tamaños que no conoce, usa exactamente los valores que el propio sitio solicita.
Paso 6: Cuando la Galería Solo Existe Después de JavaScript
Si las fotos no están en el HTML, ni en el JSON-LD, ni en una respuesta de API que puedas llamar, renderiza la página. Con Playwright, el truco más robusto es escuchar la red en lugar del DOM: cada imagen que carga el navegador pasa por ahí como respuesta, sea cual sea el atributo o el script que la puso.
from playwright.sync_api import sync_playwright
image_urls = []
def on_response(response):
if response.request.resource_type == "image" and response.ok:
image_urls.append(response.url)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.on("response", on_response)
page.goto(url, wait_until="domcontentloaded")
for _ in range(8): # scroll to trigger lazy loading
page.mouse.wheel(0, 1500)
page.wait_for_timeout(500)
html = page.content()
browser.close()
La captura de red también recoge logos e iconos, así que aplica los mismos filtros del Paso 4. Combínala con el Paso 2: el html renderizado te permite limitarte a la galería y leer los atributos finales, mientras que la lista de red atrapa imágenes que nunca aparecieron en el DOM. Si un carrusel carga las diapositivas solo al pulsar “siguiente”, recórrelo con clics antes de recoger nada. La propia guía de acciones de entrada de Playwright trata los clics, la escritura y otras interacciones con más detalle.
Paso 7: Descarga Como un Navegador
La última trampa es la propia descarga. Los servidores de imágenes suelen ser más estrictos que la página.
- Envía una cabecera
Referercon la URL de la página. La protección contra hotlinking la comprueba y, sin ella, devuelve un 403 o una imagen sustituta. - Envía un
User-Agentnormal y una cabeceraAcceptde imagen, por ejemploimage/avif,image/webp,image/*,*/*;q=0.8. - Comprueba el
Content-Type. Si empieza portext/html, has recibido una página de error o un desafío, no una imagen. Toma la extensión del archivo del tipo de contenido, porque una URL que termina en.jpgpuede servir WebP o AVIF. - Cachea por URL y no descargues nunca la misma imagen dos veces. Las peticiones de imágenes son pesadas, y saturar un CDN es la forma más rápida de que te limiten.
- Limita el ritmo. Unas pocas descargas en paralelo con una pequeña pausa bastan para la mayoría de los trabajos.
import hashlib
headers = {
"User-Agent": UA,
"Referer": url,
"Accept": "image/avif,image/webp,image/*,*/*;q=0.8",
}
r = requests.get(img_url, headers=headers, timeout=30)
ctype = r.headers.get("Content-Type", "")
if r.ok and ctype.startswith("image/"):
ext = ctype.split("/")[1].split(";")[0].replace("jpeg", "jpg")
name = hashlib.sha1(img_url.encode()).hexdigest()[:16] # stable name = free cache key
with open(f"images/{name}.{ext}", "wb") as f:
f.write(r.content)
Las fotos suelen estar protegidas por derechos de autor. Recoger URL o metadatos para analizarlos no es lo mismo que volver a publicar las fotos de otra persona, así que revisa los términos del sitio y tu caso de uso. Nuestra guía sobre la legalidad del web scraping cubre lo básico. No es asesoramiento legal.
Preguntas Frecuentes
¿Por qué BeautifulSoup me da un GIF en base64 en lugar de la URL de la imagen?
El sitio usa lazy loading. El src contiene un pequeño marcador de posición y la URL real está en un atributo como data-src o data-srcset. Lee esos primero, como se muestra en el Paso 3.
¿Cómo obtengo la imagen más grande de un srcset?
Divide el atributo por comas, lee el descriptor que sigue a cada URL (800w o 2x) y quédate con la URL que tenga el número más alto. Si el tamaño más grande sigue siendo pequeño, busca el patrón de URL del lightbox descrito en el Paso 5.
¿Por qué mis “imágenes” descargadas se abren como HTML?
El servidor de imágenes devolvió una página de error, una redirección a un login o un desafío anti-bot en lugar del archivo. Añade un Referer y cabeceras de navegador, comprueba el Content-Type antes de guardar y baja el ritmo.
¿Necesito un navegador headless para hacer scraping de imágenes? A menudo no. Los datos estructurados, los atributos de lazy loading y el propio JSON del sitio cubren la mayoría de las páginas con peticiones HTTP simples. Usa un navegador cuando la galería se construya por completo con JavaScript o solo cargue al interactuar.
Para Terminar
Hacer scraping de imágenes es sobre todo un problema de parsing, no de acceso. Revisa primero los datos estructurados, limita el selector a la galería, lee los atributos de lazy loading y srcset, filtra por tamaño y hash perceptual, y descarga con las cabeceras que enviaría un navegador. Esa combinación convierte “un logo y un GIF gris” en el conjunto completo de fotos.
Cuando el acceso sí se convierte en el problema, ScrapeUnblocker puede ayudarte en ambos extremos. Devuelve el HTML renderizado de una página, y sus browser steps pueden hacer scroll o clic por una galería antes de devolver el HTML. Para los servidores de imágenes que responden a una petición simple con una página de desafío o un píxel de seguimiento, el endpoint de obtención de imágenes carga el archivo a través de un navegador real y devuelve los bytes de la imagen.
Prueba ScrapeUnblocker gratis
Tasa de éxito del 95%+ · desde 0,55 € por cada 1000 llamadas (puede aplicarse IVA) · 500 solicitudes gratis al registrarte.