← Tous les articles

Comment Scraper les Images d'un Site Web : Lazy Loading, srcset et le Piège du Logo

Vous lancez votre scraper sur une annonce immobilière pour récupérer les photos de l’appartement. Le script tourne sans erreur et renvoie trois URL d’images : le logo de l’agence, une photo de l’agent et un GIF gris de 1x1. Les douze photos de l’appartement que vous avez vues dans votre navigateur n’apparaissent nulle part.

Personne ne vous a bloqué. La page s’est chargée normalement. Le problème, c’est que <img src="..."> est l’endroit le moins fiable pour chercher une image sur un site moderne. Les vraies URL se trouvent dans d’autres attributs, dans des données structurées, dans du JSON, ou n’apparaissent qu’après l’exécution du JavaScript. Et la page est pleine d’images dont vous ne voulez pas.

Ce guide explique où se cachent réellement les URL d’images, comment choisir les bonnes et comment les télécharger sans recevoir du HTML ou un 403 en retour.

Pourquoi img src Vous Ment

Un scraper d’images naïf fait soup.find_all("img") et lit src. Cela échoue pour cinq raisons courantes :

  • Lazy loading. Beaucoup de sites placent une image de substitution dans src (un data:image/gif en base64, un spinner ou blank.gif) et gardent la vraie URL dans data-src, data-lazy-src, data-original ou data-srcset. Un script les échange quand l’image entre dans la zone visible. Votre client HTTP ne fait jamais défiler la page.
  • Images responsive. srcset liste plusieurs tailles de la même image, et <picture> ajoute des éléments <source> par format. Le src de secours est souvent la plus petite version.
  • Galeries en JavaScript. Un carrousel peut ne rendre que la première diapositive dans le HTML. Les autres photos arrivent en JSON et sont injectées ensuite.
  • Arrière-plans CSS. Certaines galeries utilisent <div style="background-image: url(...)"> au lieu d’un <img>.
  • Bruit. Logos, avatars, photos d’agents, icônes, badges, créations publicitaires, pixels de suivi et miniatures d‘“annonces similaires” sont aussi des balises <img>. Sur une page d’annonce, ils peuvent être plus nombreux que les photos que vous cherchez.

La solution tient en une courte liste de vérifications, dans cet ordre.

Étape 1 : Regardez d’Abord les Données Structurées

Avant d’analyser le moindre <img>, cherchez les images que le site décrit déjà dans un format lisible par les machines. Ce sont généralement les images principales du contenu, en pleine taille, et plus propres que tout ce qui se trouve dans la mise en page.

  • JSON-LD. Les pages de produit, d’annonce et d’article incluent souvent un bloc <script type="application/ld+json"> avec un champ image, parfois un tableau complet d’URL de la galerie.
  • Open Graph. <meta property="og:image"> est l’image utilisée pour les aperçus de liens. C’est généralement la photo principale, mais sur certains sites c’est le logo de la marque, alors vérifiez.
  • État du framework. Next.js et les frameworks similaires intègrent les données de la page dans __NEXT_DATA__ ou dans un objet window.__INITIAL_STATE__. Les URL de la galerie y figurent souvent en entier.
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 galerie est chargée par une requête séparée, ouvrez les DevTools, filtrez l’onglet Network sur Fetch/XHR et cherchez une réponse remplie d’URL d’images. Nous détaillons cette technique dans comment trouver l’API JSON cachée derrière un site web en JavaScript.

Étape 2 : Ciblez la Galerie, Pas Toute la Page

C’est la solution au piège du logo. Ne collectez pas toutes les images de la page pour les filtrer ensuite. Trouvez l’élément qui contient la galerie de photos et ne cherchez qu’à l’intérieur.

Ouvrez la page dans votre navigateur, faites un clic droit sur une photo de la galerie, choisissez Inspecter et remontez le DOM jusqu’au conteneur : par exemple un div dont la classe contient gallery, carousel, slider ou media, ou un élément avec itemprop="image". Utilisez-le comme racine.

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

Les sélecteurs larges ci-dessus sont un point de départ, pas une règle. Chaque site nomme les choses différemment, alors fixez le sélecteur sur le vrai conteneur dès que vous l’avez trouvé. La fiche de l’agent, le logo de l’en-tête et le bandeau d‘“annonces similaires” se trouvent hors de la galerie, ils disparaissent donc automatiquement.

Étape 3 : Lisez Tous les Attributs Qui Peuvent Contenir une URL

Dans la galerie, vérifiez les attributs de lazy loading avant src, et prenez le plus grand candidat de chaque srcset. Résolvez les URL relatives et les URL sans protocole (//cdn.example.com/...) par rapport à l’URL de la page.

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

Notez que srcset utilise deux types de descripteurs : la largeur (800w) et la densité de pixels (2x). Les deux fonctionnent avec la règle “prenez le plus grand nombre” tant qu’un même attribut ne les mélange pas, ce que la spécification HTML interdit de toute façon.

Étape 4 : Filtrez le Bruit

Même dans la galerie, vous attraperez quelques déchets. Filtrez-les en deux passes.

Avant le téléchargement, à partir de l’URL et des attributs :

  • Écartez les URI data: et les fichiers .svg (les icônes et les logos sont presque toujours en SVG).
  • Écartez les URL ou noms de classe contenant des mots comme logo, avatar, icon, sprite, placeholder, badge ou pixel.
  • Écartez les images dont les attributs width ou height sont minuscules, par exemple moins de 100 pixels.
  • Dédoublonnez. La même photo apparaît souvent deux fois : une fois dans la bande de miniatures et une fois comme diapositive principale.

Après le téléchargement, à partir du fichier réel :

  • Ouvrez-le avec Pillow et écartez tout ce qui est sous une taille minimale, par exemple 300 pixels sur le petit côté. Cela attrape les images de substitution passées entre les mailles.
  • Pour les quasi-doublons (la même photo en deux tailles ou deux compressions), comparez des hashes perceptuels avec la bibliothèque imagehash plutôt que des hashes d’octets.
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

Étape 5 : Passez des Miniatures aux Images en Pleine Taille

Parfois, le HTML ne contient que des miniatures, et l’image en pleine taille n’est chargée que lorsqu’un utilisateur ouvre la lightbox. Beaucoup de CDN d’images encodent la taille dans l’URL, soit en paramètre de requête (?w=320, ?width=320&quality=60), soit en segment de chemin (/320x240/, _thumb).

Ne devinez pas le motif. Ouvrez la lightbox dans votre navigateur, passez le filtre de l’onglet Network sur Img et comparez l’URL de la grande image avec la miniature que vous avez récupérée. En général, un seul segment change. Une fois la règle connue pour ce site, appliquez-la à toutes les URL de miniatures. Si le CDN refuse les tailles qu’il ne connaît pas, tenez-vous-en aux valeurs exactes que le site demande lui-même.

Étape 6 : Quand la Galerie N’Existe Qu’Après le JavaScript

Si les photos ne sont ni dans le HTML, ni dans le JSON-LD, ni dans une réponse d’API que vous pouvez appeler, faites le rendu de la page. Avec Playwright, l’astuce la plus robuste consiste à écouter le réseau plutôt que le DOM : chaque image chargée par le navigateur y passe sous forme de réponse, quel que soit l’attribut ou le script qui l’a placée.

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 capture réseau ramasse aussi les logos et les icônes, alors appliquez les mêmes filtres qu’à l’étape 4. Combinez-la avec l’étape 2 : le html rendu vous permet de cibler la galerie et de lire les attributs finaux, tandis que la liste réseau attrape les images qui ne sont jamais apparues dans le DOM. Si un carrousel ne charge les diapositives qu’au clic sur “suivant”, parcourez-le avant de collecter. Le guide officiel de Playwright sur les actions de saisie traite plus en détail des clics, de la frappe et des autres interactions.

Étape 7 : Téléchargez Comme un Navigateur

Le dernier piège, c’est le téléchargement lui-même. Les serveurs d’images sont souvent plus stricts que la page.

  • Envoyez un en-tête Referer avec l’URL de la page. La protection anti-hotlinking le vérifie et, sans lui, renvoie un 403 ou une image de remplacement.
  • Envoyez un User-Agent normal et un en-tête Accept pour les images, par exemple image/avif,image/webp,image/*,*/*;q=0.8.
  • Vérifiez le Content-Type. S’il commence par text/html, vous avez reçu une page d’erreur ou un challenge, pas une image. Déduisez l’extension du fichier du type de contenu, car une URL qui se termine par .jpg peut servir du WebP ou de l’AVIF.
  • Mettez en cache par URL et ne téléchargez jamais deux fois la même image. Les requêtes d’images sont lourdes, et surcharger un CDN est le moyen le plus rapide de se faire limiter.
  • Ralentissez. Quelques téléchargements en parallèle avec une petite pause suffisent pour la plupart des tâches.
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)

Les photos sont généralement protégées par le droit d’auteur. Collecter des URL ou des métadonnées pour les analyser n’est pas la même chose que republier les photos de quelqu’un d’autre, alors vérifiez les conditions du site et votre cas d’usage. Notre guide sur la légalité du web scraping couvre les bases. Ce n’est pas un conseil juridique.

FAQ

Pourquoi BeautifulSoup me renvoie-t-il un GIF en base64 au lieu de l’URL de l’image ? Le site utilise le lazy loading. Le src contient une minuscule image de substitution et la vraie URL se trouve dans un attribut comme data-src ou data-srcset. Lisez-les en premier, comme montré à l’étape 3.

Comment obtenir la plus grande image d’un srcset ? Découpez l’attribut sur les virgules, lisez le descripteur qui suit chaque URL (800w ou 2x) et prenez l’URL avec le plus grand nombre. Si la plus grande taille reste petite, cherchez le motif d’URL de la lightbox décrit à l’étape 5.

Pourquoi mes “images” téléchargées s’ouvrent-elles comme du HTML ? Le serveur d’images a renvoyé une page d’erreur, une redirection vers une connexion ou un challenge anti-bot au lieu du fichier. Ajoutez un Referer et des en-têtes de navigateur, vérifiez le Content-Type avant d’enregistrer et ralentissez.

Ai-je besoin d’un navigateur headless pour scraper des images ? Souvent non. Les données structurées, les attributs de lazy loading et le JSON du site lui-même couvrent la plupart des pages avec de simples requêtes HTTP. Utilisez un navigateur quand la galerie est entièrement construite en JavaScript ou ne se charge qu’après une interaction.

Pour Conclure

Scraper des images est surtout un problème de parsing, pas d’accès. Regardez d’abord les données structurées, limitez votre sélecteur à la galerie, lisez les attributs de lazy loading et srcset, filtrez par taille et par hash perceptuel, et téléchargez avec les en-têtes qu’enverrait un navigateur. Cette combinaison transforme “un logo et un GIF gris” en l’ensemble complet des photos.

Quand l’accès devient vraiment le problème, ScrapeUnblocker peut vous aider aux deux bouts. Il renvoie le HTML rendu d’une page, et ses browser steps peuvent faire défiler ou cliquer dans une galerie avant de renvoyer le HTML. Pour les serveurs d’images qui répondent à une requête simple par une page de challenge ou un pixel de suivi, l’endpoint de récupération d’images charge le fichier dans un vrai navigateur et renvoie les octets de l’image.

Essayez ScrapeUnblocker gratuitement

Taux de réussite de plus de 95 % · à partir de 0,55 € pour 1 000 appels (TVA éventuelle en sus) · 500 requêtes gratuites à l'inscription.

Essayer gratuitement → Voir les tarifs