← Alle Artikel

Bilder von Einer Website Scrapen: Lazy Loading, srcset und die Logo-Falle

Du richtest deinen Scraper auf ein Immobilieninserat, um die Fotos der Wohnung einzusammeln. Das Skript läuft ohne Fehler durch und liefert drei Bild-URLs: das Logo der Agentur, ein Porträt des Maklers und ein graues 1x1-GIF. Die zwölf Wohnungsfotos, die du gerade noch im Browser gesehen hast, tauchen in der Ausgabe nirgends auf.

Niemand hat dich blockiert. Die Seite ist ganz normal geladen. Das Problem ist, dass <img src="..."> auf einer modernen Website der unzuverlässigste Ort ist, um nach einem Bild zu suchen. Die echten URLs stecken in anderen Attributen, in strukturierten Daten, in JSON, oder sie erscheinen erst, nachdem JavaScript gelaufen ist. Und die Seite ist voller Bilder, die du gar nicht willst.

Dieser Leitfaden zeigt, wo sich Bild-URLs wirklich verstecken, wie du die richtigen auswählst und wie du sie herunterlädst, ohne HTML oder einen 403 zurückzubekommen.

Warum img src Dich Anlügt

Ein naiver Bild-Scraper ruft soup.find_all("img") auf und liest src. Das scheitert aus fünf typischen Gründen:

  • Lazy Loading. Viele Seiten setzen einen Platzhalter in src (ein base64-data:image/gif, einen Spinner oder blank.gif) und legen die echte URL in data-src, data-lazy-src, data-original oder data-srcset ab. Ein Skript tauscht sie aus, sobald das Bild in den sichtbaren Bereich scrollt. Dein HTTP-Client scrollt nie.
  • Responsive Bilder. srcset listet mehrere Größen desselben Bildes auf, und <picture> ergänzt <source>-Elemente pro Format. Der src-Fallback ist oft die kleinste Version.
  • JavaScript-Galerien. Ein Karussell rendert im HTML eventuell nur die erste Folie. Die restlichen Fotos kommen als JSON und werden später eingefügt.
  • CSS-Hintergründe. Manche Galerien verwenden <div style="background-image: url(...)"> statt überhaupt eines <img>.
  • Rauschen. Logos, Avatare, Maklerfotos, Icons, Badges, Werbemittel, Tracking-Pixel und Vorschaubilder für “ähnliche Inserate” sind ebenfalls <img>-Tags. Auf einer Inseratsseite können sie die gewünschten Fotos zahlenmäßig übertreffen.

Die Lösung ist eine kurze Checkliste, in dieser Reihenfolge.

Schritt 1: Prüfe Zuerst die Strukturierten Daten

Bevor du auch nur ein einziges <img> parst, such nach Bildern, die die Seite bereits maschinenlesbar beschreibt. Das sind meist die Hauptbilder des Inhalts, in voller Größe und sauberer als alles im Layout.

  • JSON-LD. Produkt-, Inserats- und Artikelseiten enthalten oft einen <script type="application/ld+json">-Block mit einem image-Feld, manchmal mit einem kompletten Array von Galerie-URLs.
  • Open Graph. <meta property="og:image"> ist das Bild für Linkvorschauen. Meist ist es das Hauptfoto, auf manchen Seiten aber das Markenlogo, also prüf es nach.
  • Framework-State. Next.js und ähnliche Frameworks betten die Seitendaten in __NEXT_DATA__ oder ein window.__INITIAL_STATE__-Objekt ein. Dort stehen die Galerie-URLs oft vollständig.
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]

Wenn die Galerie über eine separate Anfrage geladen wird, öffne die DevTools, filtere den Network-Tab nach Fetch/XHR und such nach einer Antwort voller Bild-URLs. Diese Technik erklären wir ausführlich in so findest du die versteckte JSON-API hinter einer JavaScript-Website.

Schritt 2: Beschränke Dich auf die Galerie, Nicht auf die Ganze Seite

Das ist die Lösung für die Logo-Falle. Sammle nicht alle Bilder der Seite ein, um sie danach zu filtern. Finde das Element, das die Fotogalerie umschließt, und such nur darin.

Öffne die Seite im Browser, klick mit der rechten Maustaste auf ein Galeriefoto, wähle Untersuchen und geh im DOM nach oben, bis du beim Container ankommst: etwa ein div mit einer Klasse, die gallery, carousel, slider oder media enthält, oder ein Element mit itemprop="image". Nimm ihn als Wurzel.

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

Die breiten Selektoren oben sind ein Ausgangspunkt, keine Regel. Jede Seite benennt Dinge anders, also leg den Selektor auf den echten Container fest, sobald du ihn gefunden hast. Die Maklerkarte, das Logo im Header und die Leiste mit “ähnlichen Inseraten” liegen außerhalb der Galerie und fallen damit automatisch weg.

Schritt 3: Lies Jedes Attribut, das eine URL Enthalten Kann

Prüfe innerhalb der Galerie die Lazy-Loading-Attribute vor src und nimm aus jedem srcset den größten Kandidaten. Löse relative und protokollrelative URLs (//cdn.example.com/...) gegen die Seiten-URL auf.

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

Beachte, dass srcset zwei Arten von Deskriptoren kennt: Breite (800w) und Pixeldichte (2x). Beide funktionieren mit der Regel “nimm die größte Zahl”, solange ein einzelnes Attribut sie nicht mischt, was die HTML-Spezifikation ohnehin nicht erlaubt.

Schritt 4: Filtere das Rauschen Heraus

Selbst innerhalb der Galerie erwischst du etwas Müll. Filtere ihn in zwei Durchgängen.

Vor dem Download, anhand von URL und Attributen:

  • Verwirf data:-URIs und .svg-Dateien (Icons und Logos sind fast immer SVG).
  • Verwirf URLs oder Klassennamen mit Wörtern wie logo, avatar, icon, sprite, placeholder, badge oder pixel.
  • Verwirf Bilder mit winzigen width- oder height-Attributen, zum Beispiel unter 100 Pixel.
  • Entferne Duplikate. Dasselbe Foto taucht oft zweimal auf: einmal in der Vorschauleiste und einmal als Hauptfolie.

Nach dem Download, anhand der echten Datei:

  • Öffne sie mit Pillow und verwirf alles unter einer Mindestgröße, etwa 300 Pixel an der kurzen Seite. So fängst du Platzhalter ab, die durchgerutscht sind.
  • Für Beinahe-Duplikate (dasselbe Foto in zwei Größen oder Komprimierungen) vergleiche perzeptuelle Hashes mit der Bibliothek imagehash statt Byte-Hashes.
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

Schritt 5: Mach aus Vorschaubildern Bilder in Voller Größe

Manchmal enthält das HTML nur Vorschaubilder, und das Bild in voller Größe wird erst geladen, wenn jemand die Lightbox öffnet. Viele Bild-CDNs kodieren die Größe in der URL, entweder als Query-Parameter (?w=320, ?width=320&quality=60) oder als Pfadsegment (/320x240/, _thumb).

Rate das Muster nicht. Öffne die Lightbox im Browser, stell den Filter im Network-Tab auf Img und vergleiche die URL des großen Bildes mit dem Vorschaubild, das du gescrapt hast. Meist ändert sich nur ein Segment. Sobald du die Regel für diese Seite kennst, wende sie auf jede Vorschau-URL an. Wenn das CDN unbekannte Größen ablehnt, bleib bei genau den Werten, die die Seite selbst anfragt.

Schritt 6: Wenn die Galerie Erst Nach JavaScript Existiert

Wenn die Fotos weder im HTML noch im JSON-LD noch in einer API-Antwort stehen, die du aufrufen kannst, render die Seite. Mit Playwright ist der robusteste Trick, das Netzwerk statt des DOM zu beobachten: Jedes Bild, das der Browser lädt, läuft dort als Antwort durch, egal welches Attribut oder Skript es gesetzt hat.

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

Der Netzwerk-Mitschnitt sammelt auch Logos und Icons ein, also wende dieselben Filter wie in Schritt 4 an. Kombiniere ihn mit Schritt 2: Mit dem gerenderten html beschränkst du dich auf die Galerie und liest die finalen Attribute, während die Netzwerkliste Bilder erfasst, die nie im DOM aufgetaucht sind. Wenn ein Karussell Folien erst beim Klick auf “weiter” lädt, klick dich durch, bevor du sammelst. Der offizielle Playwright-Leitfaden zu Eingabeaktionen behandelt Klicks, Tippen und andere Interaktionen ausführlicher.

Schritt 7: Lade Herunter Wie ein Browser

Die letzte Falle ist der Download selbst. Bildserver sind oft strenger als die Seite.

  • Sende einen Referer-Header mit der Seiten-URL. Der Hotlink-Schutz prüft ihn und liefert ohne ihn einen 403 oder ein Ersatzbild.
  • Sende einen normalen User-Agent und einen Bild-Accept-Header, zum Beispiel image/avif,image/webp,image/*,*/*;q=0.8.
  • Prüfe den Content-Type. Beginnt er mit text/html, hast du eine Fehlerseite oder eine Challenge bekommen, kein Bild. Leite die Dateiendung aus dem Content-Type ab, denn eine URL mit .jpg am Ende kann WebP oder AVIF ausliefern.
  • Cache nach URL und lade nie dasselbe Bild zweimal herunter. Bildanfragen sind schwer, und ein CDN zu überlasten ist der schnellste Weg in ein Rate Limit.
  • Drossle das Tempo. Ein paar parallele Downloads mit einer kleinen Pause reichen für die meisten Jobs.
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)

Fotos sind in der Regel urheberrechtlich geschützt. URLs oder Metadaten für Analysen zu sammeln ist etwas anderes, als die Bilder anderer erneut zu veröffentlichen. Prüfe also die Nutzungsbedingungen der Seite und deinen Anwendungsfall. Unser Leitfaden zur Rechtslage beim Web Scraping deckt die Grundlagen ab. Er ist keine Rechtsberatung.

FAQ

Warum gibt mir BeautifulSoup ein base64-GIF statt der Bild-URL? Die Seite nutzt Lazy Loading. src enthält einen winzigen Platzhalter, und die echte URL steht in einem Attribut wie data-src oder data-srcset. Lies diese zuerst, wie in Schritt 3 gezeigt.

Wie bekomme ich das größte Bild aus einem srcset? Teile das Attribut an den Kommas, lies den Deskriptor nach jeder URL (800w oder 2x) und nimm die URL mit der höchsten Zahl. Ist auch die größte Variante noch klein, such nach dem Lightbox-URL-Muster aus Schritt 5.

Warum öffnen sich meine heruntergeladenen “Bilder” als HTML? Der Bildserver hat statt der Datei eine Fehlerseite, eine Weiterleitung zum Login oder eine Anti-Bot-Challenge geliefert. Ergänze einen Referer und browserähnliche Header, prüfe vor dem Speichern den Content-Type und mach langsamer.

Brauche ich einen Headless-Browser, um Bilder zu scrapen? Oft nicht. Strukturierte Daten, Lazy-Loading-Attribute und das seiteneigene JSON decken die meisten Seiten mit einfachen HTTP-Anfragen ab. Nimm einen Browser, wenn die Galerie komplett per JavaScript aufgebaut wird oder erst bei Interaktion lädt.

Fazit

Bilder zu scrapen ist meist ein Parsing-Problem, kein Zugriffsproblem. Prüfe zuerst die strukturierten Daten, beschränke deinen Selektor auf die Galerie, lies die Lazy-Loading- und srcset-Attribute, filtere nach Größe und perzeptuellem Hash und lade mit den Headern herunter, die ein Browser senden würde. Diese Kombination macht aus “einem Logo und einem grauen GIF” den vollständigen Satz Fotos.

Wenn der Zugriff doch zum Problem wird, kann ScrapeUnblocker an beiden Enden helfen. Es liefert das gerenderte HTML einer Seite, und seine Browser Steps können durch eine Galerie scrollen oder klicken, bevor das HTML zurückkommt. Für Bildserver, die eine einfache Anfrage mit einer Challenge-Seite oder einem Tracking-Pixel beantworten, lädt der Endpoint zum Abrufen von Bildern die Datei über einen echten Browser und gibt die Bildbytes zurück.

ScrapeUnblocker kostenlos testen

Über 95 % Erfolgsquote · ab 0,55 € pro 1.000 Aufrufe (ggf. zzgl. MwSt.) · 500 kostenlose Anfragen bei der Registrierung.

Kostenlos testen → Preise ansehen