BeautifulSoup ist die Standard-Python-Bibliothek zum Parsen von HTML und zum Herausziehen von Daten aus Webseiten. Sie lädt keine Seiten herunter — das erledigt requests — und sie führt kein JavaScript aus. Was sie besser kann als alles andere ihrer Größe: unordentliches Markup aus der echten Welt in einen Baum verwandeln, den du mit zwei Zeilen Code durchsuchst. Dieses Tutorial führt dich von pip install bis zu einem vollständigen, paginierenden Scraper, der CSV schreibt — durchgängig mit derselben Beispiel-Site — und ist am Ende ehrlich dabei, wo BeautifulSoup aufhört und wozu du danach greifen solltest. Es ist ein Kapitel unseres größeren Guides zu Web Scraping mit Python — starte dort, wenn du die ganze Landkarte willst.
Setup: bs4, lxml und requests installieren
Alles in diesem Tutorial braucht drei Pakete. Beachte: Der pip-Name ist beautifulsoup4, nicht bs4 — der Import heißt bs4, das Paket beautifulsoup4:
python -m pip install beautifulsoup4 lxml requests
BeautifulSoup selbst parst kein HTML — es delegiert an ein Parser-Backend und legt eine freundliche API über das Ergebnis. Das Backend wählst du beim Bauen der Soup:
| Parser | Konstruktor-Argument | Wann einsetzen |
|---|---|---|
| html.parser | "html.parser" | In Python eingebaut, null Abhängigkeiten. Gut für kleine Jobs und schnelle Skripte. |
| lxml | "lxml" | C-basiert und deutlich schneller. Die Standardwahl, sobald du mehr als eine Handvoll Seiten scrapst. |
| html5lib | "html5lib" | Am langsamsten, parst kaputtes Markup aber exakt so wie ein Browser. Der letzte Ausweg für pathologisches HTML. |
Zwei Faustregeln: Nimm "lxml", solange nichts dagegen spricht, und übergib den Parser immer explizit. Lässt du ihn weg, wählt BeautifulSoup den besten auf der jeweiligen Maschine installierten Parser — dasselbe Skript kann schwer kaputtes Markup auf deinem Laptop also etwas anders parsen als auf deinem Server.
Deine erste Soup
Wir verwenden für jedes Beispiel books.toscrape.com — einen Sandbox-Buchladen, der eigens zum Üben von Scraping gebaut wurde, mit 1.000 Büchern auf 50 paginierten Seiten. Hol die Startseite und parse sie:
import requests
from bs4 import BeautifulSoup
resp = requests.get("https://books.toscrape.com/")
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "lxml")
print(soup.title.get_text(strip=True))
# All products | Books to Scrape - Sandbox
Eine bewusste Entscheidung steckt darin: Wir übergeben resp.content (rohe Bytes), nicht resp.text. Diese Site lässt — wie viele echte auch — den Charset im Content-Type-Header weg, resp.text dekodiert die Seite deshalb falsch, und aus jedem £ wird £. Bekommt BeautifulSoup Bytes, erschnüffelt es das Encoding aus dem Dokument selbst und macht es richtig. Mehr dazu in der Fehlertabelle weiter unten.
Das soup-Objekt ist das ganze Dokument als durchsuchbarer Baum. Jedes Buch auf der Seite steckt in einem article-Tag mit der Klasse product_pod — dieser eine Fakt treibt alles Weitere an.
find und find_all
find_all gibt eine Liste aller passenden Elemente zurück; find liefert den ersten Treffer oder None:
books = soup.find_all("article", class_="product_pod")
print(len(books)) # 20 — one per book on the page
first = soup.find("article", class_="product_pod")
Beachte class_ mit Unterstrich am Ende, weil class in Python ein reserviertes Wort ist. Nach demselben Muster filterst du auf jedes beliebige Attribut (soup.find_all("a", href=True)), übergibst ein Dict für sperrige Attributnamen (attrs={"data-id": "42"}), begrenzt die Ergebnisse mit limit=5 oder durchsuchst mit recursive=False nur direkte Kinder. Wenn du in einem alten Tutorial findAll siehst: Das ist die veraltete Schreibweise von find_all aus der Zeit vor 4.0 — dieselbe Methode.
CSS-Selektoren: select und select_one
Seit bs4 4.7 ist volle CSS-Selektor-Unterstützung über die mitgelieferte soupsieve-Bibliothek an Bord. select gibt eine Liste zurück, select_one den ersten Treffer oder None:
books = soup.select("article.product_pod")
first_price = soup.select_one("article.product_pod p.price_color")
print(first_price.get_text(strip=True)) # £51.77
Alles, was du in der Suche der Browser-Dev-Tools schreiben kannst, funktioniert auch hier: Nachfahren-Kombinatoren (section ol li), Attribut-Selektoren (a[href^="catalogue/"]), Pseudo-Klassen (li:nth-of-type(3)). find_all und select überschneiden sich fast vollständig — Selektoren sind knapper für verschachtelte Pfade, find_all ist einfacher, wenn du per Funktion oder Regex filterst. Wähle einen Stil pro Projekt, damit der Code lesbar bleibt.
Attribute und Text
Bei Attributen verhalten sich Tags wie Dictionaries. Eckige Klammern werfen bei fehlendem Attribut einen KeyError; .get() gibt stattdessen None zurück:
link = first.h3.a # dot access walks to the first matching child
print(link["href"]) # catalogue/a-light-in-the-attic_1000/index.html
print(link["title"]) # A Light in the Attic
print(link.get("rel")) # None — no such attribute, no crash
Beachte: Die Seite kürzt den sichtbaren Link-Text („A Light in the ...“), speichert den vollen Titel aber im title-Attribut — echte Sites verstecken ständig Daten in Attributen, also prüfe sie, bevor du dich mit sichtbarem Text abmühst. Für Text ist get_text(strip=True) fast immer das Richtige, weil es Whitespace zusammenfaltet; das nackte .get_text() bewahrt den rohen Whitespace zwischen verschachtelten Tags.
Nach oben und zur Seite navigieren
Manchmal ist das Element, das du selektieren kannst, nicht das Element, das du brauchst — du triffst einen Titel, willst aber den Preis daneben. Jedes Tag hat .parent, .children und Geschwister-Hooks:
title_link = soup.find("a", title="A Light in the Attic")
pod = title_link.find_parent("article") # climb to the container
price = pod.select_one("p.price_color") # search back down
availability = pod.select_one("p.instock.availability")
print(price.get_text(strip=True), "|", availability.get_text(strip=True))
# £51.77 | In stock
find_parent klettert nach oben bis zum Treffer; find_next_sibling / find_previous_sibling bewegen sich zur Seite. Das verlässliche Muster für Listing-Seiten ist genau das, was dieser Schnipsel tut: den wiederkehrenden Container lokalisieren und dann innerhalb davon suchen — scrape Preise und Titel niemals in zwei getrennten globalen Abfragen in der Hoffnung, dass die Listen zueinander passen.
Praxisaufgabe: Produktliste zu CSV
Hier ist ein vollständiges, lauffähiges Skript, das eine Listing-Seite in eine Liste von Dicts scrapt und CSV schreibt. Das einzige sperrige Feld ist die Sterne-Bewertung, die die Site als Klassennamen kodiert (star-rating Three):
import csv
import requests
from bs4 import BeautifulSoup
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
def parse_book(pod):
"""Turn one article.product_pod into a dict."""
link = pod.h3.a
rating_classes = pod.select_one("p.star-rating")["class"] # ["star-rating", "Three"]
return {
"title": link["title"],
"price": pod.select_one("p.price_color").get_text(strip=True),
"rating": RATINGS.get(rating_classes[-1]),
"in_stock": "In stock" in pod.select_one("p.availability").get_text(),
"url": link["href"],
}
resp = requests.get("https://books.toscrape.com/")
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "lxml")
books = [parse_book(pod) for pod in soup.select("article.product_pod")]
with open("books.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=books[0].keys())
writer.writeheader()
writer.writerows(books)
print(f"Wrote {len(books)} books to books.csv")
Zwei Gewohnheiten aus diesem Skript lohnt es sich zu stehlen: eine parse_book-Funktion, die genau einen Container verantwortet (eine Layout-Änderung bricht so eine Funktion, nicht dein ganzes Skript), und Dicts zu bauen, bevor du den CSV-Writer anfasst, damit die Datenstruktur für sich allein testbar ist. Wenn dein Ziel ein Spreadsheet statt einer CSV-Datei ist, fließt dieselbe Dict-Liste direkt in den Workflow aus unserem Guide Website zu Excel scrapen.
Pagination und Höflichkeit
Die Sandbox hat 50 Seiten, jede verlinkt die nächste über li.next a. Folge diesem Link, bis er verschwindet — und sei höflich dabei: Gib dich mit einem User-Agent-Header zu erkennen statt mit dem Standard python-requests/x.y, und schlafe zwischen den Requests, damit du den Server nicht bombardierst. Die meisten Sites erzwingen Rate limits, und die Sites, die es nicht tun, verdienen die Rücksicht trotzdem:
import csv
import time
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
HEADERS = {"User-Agent": "books-tutorial/1.0 (learning BeautifulSoup)"}
def scrape_all(start_url):
url, books = start_url, []
while url:
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "lxml")
books += [parse_book(pod) for pod in soup.select("article.product_pod")]
next_link = soup.select_one("li.next a") # None on the last page
url = urljoin(url, next_link["href"]) if next_link else None
time.sleep(1) # be polite
return books
books = scrape_all("https://books.toscrape.com/")
print(f"Scraped {len(books)} books") # 1000
urljoin ist wichtiger, als es aussieht: Der Next-Page-href ist relativ (catalogue/page-2.html), und — eine echte Eigenheit dieser Site — das Pfad-Präfix ändert sich zwischen Seite 1 und Seite 2. Jeden Link gegen die tatsächlich geholte URL aufzulösen behandelt das automatisch; String-Verkettung würde auf Seite 3 mit 404 scheitern.
Häufige Fehler und ihre Lösungen
Vier Fehlerbilder machen den Großteil der BeautifulSoup-Debugging-Zeit aus:
| Fehler | Ursache | Lösung |
|---|---|---|
AttributeError: 'NoneType' object has no attribute 'get_text' | find / select_one hat nichts getroffen, und du hast eine Methode auf dem zurückgegebenen None aufgerufen | Prüfe auf None, bevor du dereferenzierst, oder mach fehlende Felder explizit: el.get_text(strip=True) if el else None |
Der Selektor liefert [] auf einer Seite, die die Daten eindeutig enthält | Falscher Klassenname, der Inhalt steckt in einem iframe, oder er wird per JavaScript gerendert und fehlt im rohen HTML | Gib resp.text (oder soup.prettify()) aus und durchsuche es — debugge immer gegen das, was Python geholt hat, nicht gegen das, was dein Browser zeigt |
Mojibake: £ oder ’, wo £ oder ein Apostroph stehen sollte | Der Server hat den Charset weggelassen (oder falsch angegeben), resp.text hat die Bytes also falsch dekodiert — books.toscrape.com selbst löst das aus | Übergib rohe Bytes und lass bs4 das Encoding erschnüffeln: BeautifulSoup(resp.content, "lxml"), wie jedes Skript in diesem Post — oder setze vorher resp.encoding = resp.apparent_encoding |
| Das Skript läuft lokal, bricht aber auf dem Server | Kein Parser angegeben, jede Maschine hat also den besten installierten genommen — und die reparieren kaputtes Markup unterschiedlich | Übergib den Parser immer explizit: BeautifulSoup(html, "lxml") |
Die zweite Zeile verdient Nachdruck, weil sie irgendwann jeden erwischt: Dein Browser ist nicht dein Scraper. Dev-Tools zeigen das DOM, nachdem JavaScript gelaufen ist; requests sieht nur die anfängliche HTML-Antwort. Wenn die beiden sich widersprechen, rettet dich kein Selektor — womit wir beim ehrlichen Teil wären.
Wenn BeautifulSoup nicht mehr reicht
BeautifulSoup hat zwei harte Grenzen, und sie zu kennen spart dir Tage.
JavaScript-gerenderte Seiten. Baut eine Site ihren Inhalt clientseitig auf — Infinite-Scroll-Feeds, React-Storefronts, Dashboards — ist das HTML, das requests herunterlädt, eine fast leere Hülle, und BeautifulSoup kann nur parsen, was tatsächlich da ist. Du brauchst einen Headless-Browser, der das JavaScript zuerst ausführt. Unser Playwright-Web-Scraping-Tutorial zeigt den modernen Weg dorthin (und Selenium web scraping den älteren); ein verbreiteter Hybrid: den Browser rendern lassen und dann page.content() an BeautifulSoup übergeben — für genau das Parsen, das du schon kennst.
Anti-Bot-Systeme im großen Maßstab. Die Sandbox blockiert dich nie. Amazon, Google und die meisten kommerziellen Sites tun es — mit CAPTCHAs, TLS-Fingerprinting und IP-Sperren —, sobald dein Traffic nicht mehr wie ein Mensch aussieht. Das selbst zu lösen heißt Proxy-Pools, Fingerprint-Rotation und Retry-Logik: ein zweites Engineering-Projekt, angeschraubt an deinen Parser. Die Alternative ist eine strukturierte Scraping API, die das Blocken übernimmt und JSON zurückgibt — es gibt dann schlicht kein HTML mehr zu parsen:
import requests
resp = requests.get(
"https://api.crawlora.net/api/v1/amazon/search",
params={"k": "mechanical keyboard"},
headers={"x-api-key": "YOUR_API_KEY"},
)
for item in resp.json()["data"]:
print(item["asin"], item["price"], item["title"])
Das ist Crawloras Amazon-Search-Endpoint — einer aus einem Katalog von Pro-Plattform-Endpoints, dokumentiert in den API-Docs. Abgerechnet wird pay-on-success: Du zahlst nur für erfolgreiche 2xx-Antworten, und die kostenlose Stufe umfasst 2.000 Credits/Monat, keine Karte. Einen umfassenderen Rahmen dafür, wann du HTML selbst parst und wann du eine API aufrufst, liefert web scraping vs API; die Zahlen stehen unter Pricing.
Nichts davon schmälert, was du in diesem Tutorial gebaut hast. Für statische, öffentliche, vernünftig große Sites — und das ist ein großer Teil des Webs — bleibt requests plus BeautifulSoup das einfachste Werkzeug, das funktioniert, und jede Fähigkeit von oben überträgt sich direkt auf die browser-gerenderten und API-gestützten Setups, in die du hineinwächst.
Spar dir das Parsen auf den harten Sites
Dokumentierte Pro-Plattform-Endpoints, normalisiertes JSON, gemanagte Proxys und Retries. Zahle nur für erfolgreiche 2xx-Antworten — 2.000 Credits/Monat, keine Karte.
Weiterführende Lektüre: der Pillar-Guide Web Scraping mit Python · Web Scraping mit Playwright für JavaScript-lastige Sites · Selenium web scraping · Website zu Excel scrapen · Was ist HTML-Parsing.
Häufig gestellte Fragen
Wofür wird BeautifulSoup verwendet?
BeautifulSoup ist eine Python-Bibliothek zum Parsen von HTML und XML und zum Extrahieren von Daten daraus. Sie verwandelt Markup in einen durchsuchbaren Baum, den du mit find, find_all oder CSS-Selektoren abfragst. Sie lädt keine Seiten herunter (kombiniere sie mit requests) und führt kein JavaScript aus.
Was ist der Unterschied zwischen find_all und select in BeautifulSoup?
find_all sucht per Python-Argumenten nach Tag-Name, Klasse und Attributen, während select CSS-Selektor-Strings über die mitgelieferte soupsieve-Engine nutzt. Sie überschneiden sich fast vollständig: Selektoren sind knapper für verschachtelte Pfade, find_all ist einfacher für Funktions- oder Regex-Filter. find_all gibt eine Liste zurück; find und select_one liefern den ersten Treffer oder None.
Ist BeautifulSoup schneller als lxml?
Nein — lxml ist die schnellere Bibliothek, und BeautifulSoup kann lxml sogar als Parser-Backend nutzen. BeautifulSoup(html, 'lxml') gibt dir lxmls C-basierte Geschwindigkeit mit der freundlicheren API von BeautifulSoup. Pythons eingebauter html.parser braucht keine zusätzliche Installation, ist bei großen Jobs aber spürbar langsamer.
Kann BeautifulSoup JavaScript-gerenderte Sites scrapen?
Nein. BeautifulSoup parst nur das HTML, das dein HTTP-Client heruntergeladen hat; es führt niemals JavaScript aus. Für clientseitig gerenderte Seiten nutzt du einen Headless-Browser wie Playwright zum Rendern und übergibst das gerenderte HTML anschließend optional an BeautifulSoup — oder du nutzt eine Scraping API, die direkt strukturiertes JSON zurückgibt.
Warum gibt BeautifulSoup None oder eine leere Liste zurück?
Entweder ist der Selektor falsch, oder die Daten stehen nicht in dem HTML, das dein Skript geholt hat — häufig, weil sie per JavaScript gerendert werden oder in einem iframe stecken. Debugge gegen resp.text (das, was Python tatsächlich heruntergeladen hat), nicht gegen die Browser-Dev-Tools, die das DOM nach dem JavaScript-Lauf zeigen.
Welchen Parser sollte ich mit BeautifulSoup verwenden?
Nimm lxml für Geschwindigkeit bei jeder echten Arbeitslast, html.parser, wenn du keine Abhängigkeiten installieren kannst, und html5lib nur, wenn du browser-identisches Verhalten bei schwer kaputtem Markup brauchst. Übergib den Parser immer explizit — lässt du ihn weg, wählt jede Maschine ein anderes Backend, das kaputtes HTML unterschiedlich parsen kann.
Brauche ich BeautifulSoup noch, wenn ich eine Scraping API nutze?
Für unterstützte Plattformen meist nicht — eine strukturierte Scraping API wie Crawlora gibt normalisiertes JSON zurück, es gibt also kein HTML zu parsen. Nützlich bleibt BeautifulSoup für statische Sites, die du direkt scrapst, und zum Parsen von gerendertem HTML aus einem Headless-Browser.
