---
title: "Was ist BeautifulSoup und wie nutzt man es in Python?"
description: "BeautifulSoup ist eine Python-Bibliothek, die HTML in einen durchsuchbaren Baum verwandelt. Wir erklären Parser-Wahl, find_all oder select und HTML-Tabellen."
url: https://proxynet.io/de/blog/beautifulsoup-tutorial
date: 2026-09-24
author: "Acar Diveroli"
category: "Web Scraping, Anleitungen"
lang: de
---

# Was ist BeautifulSoup und wie nutzt man es in Python?

Ein Kollege bittet Sie, eine Statistiktabelle von einer Webseite nach Python zu holen. Das HTML haben Sie mit Requests heruntergeladen, und `soup.find("td").text` hat Ihnen die erste Zelle geliefert. Jetzt brauchen Sie jede Zeile, die Klasse, die manche Zellen grün und andere rot färbt, und die Seitenlinks unter der Tabelle. Der Selektor `table > tbody > tr`, den Sie aus den Entwicklertools des Browsers kopiert haben, liefert nichts. Den allgemeinen Weg von einer Seite zu einer Datei beschreibt [Daten von Websites extrahieren](/de/blog/extract-data-from-website); dieser Leitfaden behandelt die Bibliothek, die das HTML liest.

Wir behandeln die drei Parser, `find`, `find_all` und `select`, die Auswahl nach Klasse, die Bewegung im Baum, das Auslesen von Text und Links, ein vollständiges Beispiel an einer Übungstabelle sowie `pandas.read_html` mit seinen häufigen Fehlern. Jedes Beispiel lief am 24. September 2026 mit beautifulsoup4 4.15.0 und Python 3.13.

> **Hinweis: Kurzantwort**
>
> BeautifulSoup ist eine Python-Bibliothek, die HTML und XML in einen durchsuchbaren Baum verwandelt. Sie lädt keine Seiten herunter und führt kein JavaScript aus; sie parst den Text, den ihr ein Client wie Requests übergibt. Installieren Sie `beautifulsoup4`, importieren Sie `bs4` und nennen Sie immer den Parser (lxml passt für die meisten Aufgaben). `find` und `select_one` liefern ein Element oder `None`; `find_all` und `select` liefern eine Liste, die leer ist, wenn nichts passt. Text lesen Sie mit `get_text(strip=True)`, Attribute mit `tag.get("href")`. Für eine saubere `<table>` liefert `pandas.read_html` in einer Zeile einen DataFrame.

## Was ist BeautifulSoup?

BeautifulSoup ist eine Python-Bibliothek, die HTML und XML in einen Baum aus Objekten parst, den Sie durchsuchen können, auch bei fehlerhaftem Markup. Anfragen sendet sie keine. Ein HTTP-Client wie Requests oder HTTPX lädt die Seite herunter ([HTTPX, Requests und AIOHTTP im Vergleich](/de/blog/httpx-vs-requests-vs-aiohttp)), und BeautifulSoup arbeitet mit dem, was dieser Client zurückgibt.

Paketname und Importname unterscheiden sich. Sie installieren `beautifulsoup4` und importieren `bs4`:

```bash
pip install beautifulsoup4 lxml
```

Das Paket `bs4` auf PyPI ist ein Platzhalter (Version 0.0.2), der den Namen belegt und nur `beautifulsoup4` nachinstalliert. Anleitungen, die mit `from BeautifulSoup import BeautifulSoup` beginnen, wurden für BeautifulSoup 3 und Python 2 geschrieben und laufen unter Python 3 nicht. Die aktuelle Version ist 4.15.0 ([beautifulsoup4 auf PyPI](https://pypi.org/project/beautifulsoup4/)), die in der [offiziellen Dokumentation](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) beschrieben ist. Wie sich die Bibliothek zu Scrapy und Selenium verhält, steht in [Scrapy, BeautifulSoup oder Selenium?](/de/blog/scrapy-proxy).

## Wie verwandelt BeautifulSoup eine Seite in einen Baum?

Zwischen dem Download und Ihrer ersten Suche liegen fünf Schritte:

1. **Der Client lädt Bytes herunter.** Requests speichert sie in `response.content` und bietet in `response.text` eine dekodierte Vermutung an.
2. **BeautifulSoup ermittelt die Kodierung.** Eine Unterbibliothek namens Unicode, Dammit liest `<meta charset>` und andere Hinweise und wandelt die Bytes dann in Unicode um. Übergeben Sie `response.content`, nicht `response.text`: Sendet ein Server `text/html` ohne Charset, nimmt Requests ISO-8859-1 an, und aus `é` wird `Ã©`. Einzelheiten stehen in [Unicode-Kodierungsfehler in Python](/de/blog/python-unicode-encoding-errors).
3. **Der Parser liest die Tags.** Er verwandelt den Text in Elemente und repariert nicht geschlossene Tags nach seinen eigenen Regeln.
4. **Das Ergebnis ist ein Baum.** Jedes Element wird zu einem `Tag` mit Namen und Attributen, jedes Textstück zu einem `NavigableString`.
5. **Suchmethoden durchlaufen den Baum.** `find`, `find_all` und `select` lesen diesen Baum im Arbeitsspeicher und greifen nie auf das Netzwerk zu.

> **Achtung: Kein JavaScript**
>
> BeautifulSoup sieht nur das HTML, das der Server gesendet hat. Füllt der Browser die Tabelle später per JavaScript, stehen die Daten nicht in diesem HTML, und kein Selektor wird sie finden. Wie Sie die beiden Arten von Seiten unterscheiden, zeigt [Statische und dynamische Seiten](/de/blog/static-vs-dynamic-pages).

## Welchen Parser sollten Sie wählen: html.parser, lxml oder html5lib?

Jeder Parser repariert fehlerhaftes Markup auf seine eigene Weise. Wir haben allen dreien dasselbe Fragment mit nicht geschlossenen Zellen gegeben:

```python
from bs4 import BeautifulSoup

broken = "<table><tr><td>1<td>2</table>"
for parser in ("html.parser", "lxml", "html5lib"):
    print(parser, BeautifulSoup(broken, parser))
```

```text
html.parser <table><tr><td>1<td>2</td></td></tr></table>
lxml <html><body><table><tr><td>1</td><td>2</td></tr></table></body></html>
html5lib <html><head></head><body><table><tbody><tr><td>1</td><td>2</td></tr></tbody></table></body></html>
```

html.parser hat die zweite Zelle in die erste gesetzt, deshalb findet `row.find_all("td", recursive=False)` eine Zelle statt zwei. lxml hat beide Zellen geschlossen und das Fragment in `<html><body>` eingebettet. html5lib hat den Baum gebaut, den auch ein Browser bauen würde, `<tbody>` eingeschlossen.

| Parser | Aufruf | Installation | Nicht geschlossene Tags | Wann wählen |
|---|---|---|---|---|
| html.parser | `BeautifulSoup(html, "html.parser")` | Bei Python dabei | Kann eine Zelle in eine andere verschachteln | Kleine Skripte, wenn Sie keine Pakete installieren können |
| lxml | `BeautifulSoup(html, "lxml")` | `pip install lxml` (C-Erweiterung) | Schließt die Zellen, ergänzt `<html><body>` | Die meisten Scraping-Aufgaben; die Dokumentation nennt ihn sehr schnell |
| html5lib | `BeautifulSoup(html, "html5lib")` | `pip install html5lib` (reines Python) | Baut den Baum des Browsers, ergänzt `<tbody>` | Stark fehlerhafte Seiten oder wenn Sie den Baum brauchen, den der Browser zeigt; sehr langsam |

Ein nicht installierter Parser löst `bs4.FeatureNotFound: Couldn't find a tree builder with the features you requested: html5lib` aus. Ist kein Parser angegeben, wählt BeautifulSoup nach einer festen Rangfolge einen der installierten Parser und gibt eine `GuessedAtParserWarning` aus. Dasselbe Skript kann so auf einem Rechner ohne lxml einen anderen Baum bauen.

## Was ist der Unterschied zwischen find, find_all und select?

Vier Methoden decken fast jede Suche ab:

- `find(name, attrs)` liefert das erste passende Tag oder `None`.
- `find_all(name, attrs)` liefert eine Liste aller Treffer oder eine leere Liste.
- `select(css)` nimmt einen CSS-Selektor entgegen und liefert eine Liste.
- `select_one(css)` liefert den ersten Treffer eines CSS-Selektors oder `None`.

Die CSS-Methoden laufen über Soup Sieve, das zusammen mit beautifulsoup4 installiert wird. Wenn nichts passt:

```python
soup.find("td", class_="rank")        # None
soup.find_all("td", class_="rank")    # []
soup.select_one("td.rank")            # None
soup.select("td.rank")                # []

soup.find("td", class_="rank").get_text()
# AttributeError: 'NoneType' object has no attribute 'get_text'
```

Das ist ein häufiger erster Fehler: `find` hat `None` geliefert, und der nächste Aufruf ist gescheitert. Prüfen Sie, bevor Sie verketten:

```python
cell = soup.find("td", class_="name")
name = cell.get_text(strip=True) if cell else None
```

`limit=3` beendet `find_all` nach drei Treffern, und `recursive=False` durchsucht nur direkte Kinder. `select` ist kürzer, wenn der Pfad über mehrere Ebenen führt, etwa `table.table tr.team td.name`. Die Selektorsyntax und warum BeautifulSoup kein XPath kennt, erklärt [CSS-Selektor oder XPath](/de/blog/css-selector-vs-xpath).

## Wie wählen Sie nach Klasse, ID und Attribut aus?

`class` ist in Python ein reserviertes Wort, deshalb verwendet BeautifulSoup `class_`. Die Falle: `class` enthält mehrere Werte, und `td["class"]` liefert eine Liste wie `['pct', 'text-success']`. Auf der Übungsseite, die wir weiter unten verwenden, tragen die Zellen von Win % die Klasse `pct` und die Zellen von `+ / -` die Klasse `diff`, jeweils zusammen mit `text-success` oder `text-danger`. Auf einer Seite mit 25 Zeilen haben wir gezählt:

```python
soup.find_all("td", class_="text-danger")       # 31 Zellen, aus beiden Spalten
soup.find_all("td", class_="pct text-danger")   # 19 Zellen: vergleicht den exakten String
soup.find_all("td", class_="text-danger pct")   # 0 Zellen: gleiche Klassen, andere Reihenfolge
soup.select("td.pct.text-danger")               # 19 Zellen, in beliebiger Reihenfolge
```

Eine einzelne Klasse in `class_` trifft jedes Tag, das sie neben anderen trägt. Ein String mit Leerzeichen trifft nur genau diesen Attributwert und bricht, sobald die Seite die Klassen umstellt. Für zwei oder mehr Klassen nehmen Sie `select` mit Punkten. Andere Attribute funktionieren als Schlüsselwortargumente oder über `attrs`:

```python
import re

soup.find("div", id="results")                     # nach ID
soup.find_all("a", href=True)                      # nur Links, die ein href haben
soup.find("a", attrs={"aria-label": "Next"})       # Namen mit Bindestrich gehören in attrs
soup.find("th", string=re.compile("Wins"))         # nach Text
```

`string="Wins"` liefert hier `None`, weil `string` den gesamten Text vergleicht und die Zelle Zeilenumbrüche und Leerzeichen um das Wort enthält. Ein regulärer Ausdruck trifft an jeder Stelle darin.

## Wie bewegen Sie sich im Baum: parent, children und siblings?

- `.parent` geht eine Ebene nach oben, und `find_parent("table")` steigt auf, bis es auf eine Tabelle trifft.
- `.children` liefert die direkten Kinder, `.descendants` jeden Knoten darunter. Eine Zeile der Übungstabelle hat 9 Zellen, doch `.children` lieferte 19 Einträge: Die übrigen 10 sind Leerraum-Strings.
- `.next_sibling` liefert den nächsten Knoten, und das ist in eingerücktem HTML meist Leerraum.

```python
name = soup.find("td", class_="name")
name.next_sibling                                   # '\n'
name.find_next_sibling("td").get_text(strip=True)   # '1990'
```

`find_next_sibling("td")` und `find_previous_sibling("td")` springen zum nächsten oder vorherigen Tag. Mit derselben Methode lesen Sie Tabellen aus Beschriftung und Wert: `th.find_next_sibling("td")` liefert den Wert neben einer Beschriftung.

## Wie lesen Sie Text und Attribute: get_text, href und src?

`.text` behält den Leerraum, deshalb liefert die Team-Zelle den Namen umgeben von Zeilenumbrüchen und Einrückung. `get_text(strip=True)` kürzt ihn auf `'Boston Bruins'`. Enthält ein Tag weitere Tags, geben Sie ein Trennzeichen an: Für `<td>12<small>pts</small></td>` liefert `get_text(strip=True)` den Wert `'12pts'` und `get_text(" ", strip=True)` den Wert `'12 pts'`. `.stripped_strings` gibt die Teile einzeln aus.

Attribute lesen Sie wie ein Dictionary. `a["href"]` löst einen `KeyError` aus, wenn das Tag kein `href` hat; `a.get("href")` liefert `None`, was in einer Schleife sicherer ist. Relative Links wie `/pages/forms/?page_num=2` werden mit `urllib.parse.urljoin(page_url, href)` zu vollständigen Adressen, und ein Bild funktioniert genauso mit `img.get("src")`. Bilder mit Lazy Loading und `srcset` behandelt [Alle Bilder einer Website herunterladen](/de/blog/download-all-images-from-website).

## Vollständiges Beispiel: eine HTML-Tabelle Zeile für Zeile lesen

Ziel ist die Eishockey-Tabelle auf [scrapethissite.com/pages/forms](https://www.scrapethissite.com/pages/forms/), deren Seitentitel die Website als öffentliche Sandbox zum Erlernen von Web Scraping bezeichnet. Ihre `robots.txt` sperrt nur `/lessons/` und `/faq/`, und das Skript prüft sie zuerst ([robots.txt lesen](/de/blog/robots-txt)). Es liest die 25 Zeilen einer Seite, macht aus der Klasse der Win-%-Zelle ein Feld mit wahr oder falsch und sammelt die Seitenlinks.

```python
"""Eine Seite einer Übungstabelle mit Requests und BeautifulSoup lesen."""
import json
import os
import sys
from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

URL = "https://www.scrapethissite.com/pages/forms/"
USER_AGENT = "hockey-table-demo/1.0 (contact: you@example.com)"

# Optional: PROXY_URL=http://user:pass@pr.proxynet.io:8000
proxy = os.environ.get("PROXY_URL")

session = requests.Session()
session.headers["User-Agent"] = USER_AGENT
if proxy:
    session.proxies = {"http": proxy, "https": proxy}

# robots.txt einmal vor der ersten Anfrage prüfen
robots = RobotFileParser()
robots.parse(session.get(urljoin(URL, "/robots.txt"), timeout=(5, 20)).text.splitlines())
if not robots.can_fetch(USER_AGENT, URL):
    sys.exit("robots.txt does not allow this page")

response = session.get(URL, timeout=(5, 20))
response.raise_for_status()

# BeautifulSoup die Bytes übergeben und den Parser nennen
soup = BeautifulSoup(response.content, "lxml")

table = soup.select_one("table.table")
if table is None:
    sys.exit("No table.table on the page: the layout changed or the data comes from JavaScript")

headers = [th.get_text(" ", strip=True) for th in table.find("tr").find_all("th")]

teams = []
for row in table.find_all("tr", class_="team"):
    record = {}
    for td in row.find_all("td"):
        key = td["class"][0]              # "name", "year", "wins", "pct", "diff" ...
        record[key] = td.get_text(strip=True)
    # Die Website färbt Win % grün oder rot; die Farbe steht nur in der Klasse
    pct_classes = row.find("td", class_="pct").get("class", [])
    record["above_500"] = "text-success" in pct_classes
    teams.append(record)

# Seitenlinks: relative hrefs werden zu vollen URLs, Duplikate fallen weg, die Reihenfolge bleibt
page_links = list(dict.fromkeys(
    urljoin(URL, a["href"]) for a in soup.select("ul.pagination a[href]")
))

print("columns:", headers)
print("rows:", len(teams), "| page links:", len(page_links))
for team in teams[:3]:
    print(json.dumps(team, ensure_ascii=False))
print("last page:", page_links[-1])
```

Wir haben es mit beautifulsoup4 4.15.0, lxml 6.1.3 und Requests 2.34.2 ausgeführt, einmal direkt und einmal über einen lokalen Test-Proxy, der in `PROXY_URL` eingetragen war. Beide Läufe gaben dieselben Zeilen aus:

```text
columns: ['Team Name', 'Year', 'Wins', 'Losses', 'OT Losses', 'Win %', 'Goals For (GF)', 'Goals Against (GA)', '+ / -']
rows: 25 | page links: 24
{"name": "Boston Bruins", "year": "1990", "wins": "44", "losses": "24", "ot-losses": "", "pct": "0.55", "gf": "299", "ga": "264", "diff": "35", "above_500": true}
{"name": "Buffalo Sabres", "year": "1990", "wins": "31", "losses": "30", "ot-losses": "", "pct": "0.388", "gf": "292", "ga": "278", "diff": "14", "above_500": false}
{"name": "Calgary Flames", "year": "1990", "wins": "46", "losses": "26", "ot-losses": "", "pct": "0.575", "gf": "344", "ga": "263", "diff": "81", "above_500": true}
last page: https://www.scrapethissite.com/pages/forms/?page_num=24
```

Die Kopfzeile hat keine Klasse, deshalb nimmt das Skript die erste `<tr>` für die Spaltennamen und `tr.team` für die Daten. Die erste Klasse jeder Zelle (`name`, `wins`, `ot-losses`) wird zum Schlüssel im Dictionary, damit bleibt der Code unabhängig von der Reihenfolge der Spalten. Die leere Zelle OT Losses kommt als leerer String zurück, nicht als `None`. Hier wiederholt die Farbe nur die Zahl, doch auf manchen Websites, etwa bei Shops, die einen ausverkauften Artikel mit einer Klasse markieren, steht diese Information nur in der Klasse. Der Paginierungsblock hat 25 Links, weil der Pfeil „Next“ die Adresse von Seite 1 wiederholt; `dict.fromkeys` entfernt das Duplikat und behält die Reihenfolge bei.

Der User-Agent nennt das Skript und gibt eine Kontaktadresse an, statt sich als Browser auszugeben ([Was ist ein User-Agent?](/de/blog/what-is-user-agent)). `timeout=(5, 20)` gibt nach 5 Sekunden ohne Verbindung oder 20 Sekunden ohne Daten auf, und `raise_for_status()` stoppt bei einer Fehlerseite, bevor sie geparst wird. Ein falsches Proxy-Passwort löst einen `ProxyError` mit „Max retries exceeded“ und „407 Proxy Authentication Required“ in der Meldung aus ([Max Retries Exceeded With URL](/de/blog/max-retries-exceeded-with-url)). Einen Proxy brauchen Sie erst, wenn das Volumen wächst oder Sie Seiten so sehen müssen, wie Besucher in einem anderen Land sie sehen; ein [Residential-Proxy](https://proxynet.io/de/residential-proxy) passt dann in dieselbe Zeile `PROXY_URL`.

Das Skript hört absichtlich nach einer Seite auf. Wie Sie alle 24 Seiten mit einer Pause zwischen den Anfragen durchgehen, zeigt [Paginierung beim Web Scraping](/de/blog/pagination-web-scraping), das Wiederholen nach einem `429` oder `503` erklärt [HTTP-Statuscodes beim Web Scraping](/de/blog/http-status-codes-web-scraping), parallele Anfragen behandelt [Concurrency und Parallelism](/de/blog/concurrency-vs-parallelism) und den Wechsel des Ausgangs zwischen Anfragen [Proxys in Python rotieren](/de/blog/how-to-rotate-proxies-in-python).

## Wie lesen Sie eine Tabelle mit pandas read_html?

Hat die Seite eine richtige `<table>` und brauchen Sie nur die Werte, liest pandas sie mit einem einzigen Aufruf. [pandas.read_html](https://pandas.pydata.org/docs/reference/api/pandas.read_html.html) betrachtet nur die Elemente `<table>`, `<tr>`, `<th>` und `<td>` und liefert immer eine Liste von DataFrames, einen pro gefundener Tabelle. Wir haben pandas 3.0.6 verwendet:

```python
import io
import os

import pandas as pd
import requests

URL = "https://www.scrapethissite.com/pages/forms/"

session = requests.Session()
session.headers["User-Agent"] = "hockey-table-demo/1.0 (contact: you@example.com)"
if os.environ.get("PROXY_URL"):
    session.proxies = {"http": os.environ["PROXY_URL"], "https": os.environ["PROXY_URL"]}

response = session.get(URL, timeout=(5, 20))
response.raise_for_status()

# pandas 3: das HTML in StringIO verpacken, ein einfacher String gilt als Dateipfad
tables = pd.read_html(io.StringIO(response.text), attrs={"class": "table"})
df = tables[0]
print(len(tables), df.shape)
print(df[["Team Name", "Year", "Wins", "OT Losses", "Win %"]].head(3))
```

```text
1 (25, 9)
        Team Name  Year  Wins  OT Losses  Win %
0   Boston Bruins  1990    44        NaN  0.550
1  Buffalo Sabres  1990    31        NaN  0.388
2  Calgary Flames  1990    46        NaN  0.575
```

Der Server gibt im Header `Content-Type` UTF-8 an, deshalb dekodiert `response.text` hier korrekt. pandas hat auch die Zahlen umgewandelt: Year und Wins wurden zu Ganzzahlen, Win % zu einer Gleitkommazahl und die leere Spalte OT Losses zu `NaN`. `attrs` wählt eine Tabelle nach ihren Attributen aus, `match` nach einem String oder regulären Ausdruck in ihrem Text. Standardmäßig parst pandas mit lxml und weicht auf BeautifulSoup mit html5lib aus, wenn das scheitert.

Drei Fehler tauchen immer wieder auf:

- **Ein Dateifehler, wenn Sie HTML-Text übergeben.** Seit pandas 3.0 akzeptiert `read_html` keine literalen HTML-Strings mehr; verpacken Sie den Text in `io.StringIO` ([Versionshinweise zu pandas 3.0.0](https://pandas.pydata.org/docs/whatsnew/v3.0.0.html)). Ein einfacher String wird als Dateipfad behandelt, und wir erhielten einen `FileNotFoundError`, der den Anfang der Seite zitierte.
- **`ValueError: No tables found`.** Die Tabelle kommt erst später per JavaScript, die Seite zeichnet ihr Raster mit `<div>`-Elementen, oder Ihr `match`-Text steht in keiner Tabelle (`No tables found matching pattern 'Points'`). Ist html5lib nicht installiert, scheitert zuerst der Ausweich-Parser, und Sie erhalten einen `ImportError`, der Sie auffordert, html5lib zu installieren.
- **`HTTP Error 403: Forbidden`, wenn Sie die URL übergeben.** pandas lädt dann mit urllib herunter, dessen Standard-User-Agent `Python-urllib/3.13` lautet, und manche Websites lehnen ihn ab; unser lokaler Testserver hat genau diesen String protokolliert. Laden Sie die Seite wie oben selbst herunter oder übergeben Sie über `storage_options={"User-Agent": "..."}` einen ehrlichen Bot-Namen. Kopieren Sie nicht den User-Agent eines Browsers: [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-user-agent) weist darauf hin, dass ein Client, der sich als ein anderer ausgibt, die Antworten erhalten kann, die für diesen anderen Client gedacht sind.

pandas liefert nur Werte. `extract_links="body"` ergänzt den Link jeder Zelle, aber keine Klassen; für die Farbe von Win % oben kehren Sie also zu BeautifulSoup zurück.

## Anwendungsfälle

- **Preise von Wettbewerbern:** Viele Produktseiten tragen ihren Preis in einem JSON-LD-Tag `<script>`, das `find_all("script", type="application/ld+json")` ausliest ([Wettbewerberpreise überwachen](/de/blog/competitor-price-tracking)).
- **Anmeldung mit dem eigenen Konto:** Ein Login-Formular hat oft ein verstecktes CSRF-Feld, das Sie mit `find("input", attrs={"name": "csrf_token"})` lesen, bevor Sie das Formular absenden ([Sitzungen und Cookies in Python](/de/blog/python-login-session-cookies)).
- **Seitenlinks folgen:** `select_one("a[rel=next]")` oder ein Paginierungsblock zeigt dem Crawler, wo die nächste Seite liegt ([paginierte Listen scrapen](/de/blog/pagination-web-scraping)).
- **Scraping und Crawling:** BeautifulSoup ist der Extraktionsschritt; ein Crawler ergänzt den Teil, der Seiten findet ([Web Scraping und Web Crawling](/de/blog/web-scraping-vs-web-crawling)).
- **Große, geplante Datenerfassung:** Tausende Seiten am Tag brauchen Warteschlangen, Tempokontrolle und Ausgänge in mehreren Ländern ([Data Scraping](/de/data-scraping)).

## Häufige Fehler

- **Den Parser weglassen.** Das Ergebnis hängt davon ab, was auf dem jeweiligen Rechner installiert ist, und Sie erhalten eine `GuessedAtParserWarning`.
- **Nach `find` ohne Prüfung verketten.** Ein fehlendes Element wird drei Zeilen später zu `'NoneType' object has no attribute ...`.
- **Einen Selektor mit `tbody` aus den Entwicklertools kopieren.** Browser ergänzen `<tbody>`, weil HTML es Autoren erlaubt, die Tags dieses Elements wegzulassen ([WHATWG HTML, das tbody-Element](https://html.spec.whatwg.org/multipage/tables.html#the-tbody-element)). Auf der Übungsseite fand `table > tbody > tr` mit html.parser und lxml 0 Zeilen, mit html5lib 26, während `table tr.team` mit allen dreien 25 fand.
- **Mehrere Klassen als einen String suchen.** `class_="pct text-danger"` scheitert, wenn die Seite die Klassen in anderer Reihenfolge schreibt; nehmen Sie `select("td.pct.text-danger")`.
- **Erwarten, dass `.next_sibling` ein Tag ist.** In eingerücktem HTML ist es meist ein Leerraum-String; nehmen Sie `find_next_sibling("td")`.
- **`response.text` übergeben.** Ohne Charset im Header rät Requests ISO-8859-1; übergeben Sie `response.content`.
- **Eine Schleife starten, bevor Sie `robots.txt` gelesen haben.** Prüfen Sie, was die Website erlaubt, und legen Sie eine Pause fest, bevor Sie mehr als eine Seite anfragen.

## Entscheidungshilfe

| Bedarf | Empfehlung |
|---|---|
| Die Seite hat eine saubere `<table>`, und Sie wollen einen DataFrame | `pandas.read_html` mit `io.StringIO`, dazu `attrs` oder `match`, um die Tabelle auszuwählen |
| Sie brauchen auch Klasse, Farbe oder Link einer Zelle | BeautifulSoup: Zeilen mit `find_all("tr")`, Attribute mit `td.get("class")` und `a.get("href")` |
| Sie können keine zusätzlichen Pakete installieren | html.parser, und prüfen Sie das Ergebnis bei Seiten mit nicht geschlossenen Tags |
| Tempo und Toleranz gegenüber fehlerhaften Tabellen | lxml, die Standardwahl für die meisten Aufgaben |
| Der Baum weicht von dem ab, was der Browser zeigt | html5lib ausprobieren und `tbody` aus dem Selektor entfernen |
| Die Daten kommen per JavaScript | Zuerst die JSON-Anfrage suchen, dann einen Headless-Browser ([Statische und dynamische Seiten](/de/blog/static-vs-dynamic-pages)) |
| Tausende Seiten mit Warteschlangen, Wiederholungen und Proxys | Scrapy ([Scrapy mit Proxy](/de/blog/scrapy-proxy)) und [Rotierender Proxy](https://proxynet.io/de/rotating-proxy) |

## Häufige Fragen

### Lädt BeautifulSoup Webseiten herunter?

Nein. BeautifulSoup parst nur HTML, das Sie übergeben. Ein HTTP-Client wie Requests oder HTTPX lädt die Seite herunter, und Sie reichen `response.content` zusammen mit einem Parsernamen an `BeautifulSoup` weiter. JavaScript führt BeautifulSoup ebenfalls nicht aus.

### Was ist der Unterschied zwischen find und find_all?

`find` liefert das erste passende Tag oder `None`; `find_all` liefert eine Liste aller Treffer, die leer ist, wenn nichts passt. `select_one` und `select` tun dasselbe mit einem CSS-Selektor. Prüfen Sie ein Ergebnis von `find`, bevor Sie darauf eine Methode aufrufen.

### Welcher Parser ist für BeautifulSoup der richtige?

lxml passt für die meisten Aufgaben: Er ist schnell und schließt offene Tags sinnvoll. Nehmen Sie html.parser, wenn Sie keine Pakete installieren können, und html5lib, wenn Sie den Baum des Browsers brauchen und in Kauf nehmen, dass er langsam ist. Schreiben Sie den Parsernamen immer in den Aufruf.

### Kann BeautifulSoup per JavaScript geladene Inhalte lesen?

Nein. Die Bibliothek sieht nur das HTML, das der Server zurückgegeben hat, und Daten, die ein Skript später hinzufügt, stehen nicht darin. Suchen Sie im Netzwerk-Tab des Browsers die JSON-Anfrage, die die Daten liefert, oder nutzen Sie einen Headless-Browser, wo die Website es erlaubt ([Statische und dynamische Seiten](/de/blog/static-vs-dynamic-pages)).

### Warum meldet pandas read_html „No tables found“?

Die Tabelle wird per JavaScript aufgebaut, die Seite zeichnet ihr Raster mit `<div>`-Elementen statt mit einer `<table>`, oder Ihr Wert für `match` oder `attrs` passt zu keiner Tabelle. Achten Sie seit pandas 3.0 außerdem darauf, das HTML in `io.StringIO` verpackt zu übergeben; ein einfacher String wird als Dateipfad behandelt.

### Ist pip install bs4 dasselbe wie pip install beautifulsoup4?

Im Ergebnis ja, aber verwenden Sie den echten Namen. `bs4` auf PyPI ist ein Platzhalterpaket, das nur `beautifulsoup4` installiert. Installieren Sie `beautifulsoup4` und importieren Sie im Code `bs4`: `from bs4 import BeautifulSoup`.

## Fazit

BeautifulSoup parst HTML in einen Baum, lädt aber keine Seiten herunter und führt kein JavaScript aus. Nennen Sie in jedem Aufruf den Parser, und denken Sie daran, dass `find` `None` liefert, wo `find_all` eine leere Liste liefert. Nehmen Sie `select`, wenn Sie mehrere Klassen auf einmal abgleichen, und versuchen Sie es zuerst mit `pandas.read_html`, wenn die Seite eine saubere Tabelle hat. Wird aus einer Seite eine Sammlung von Tausenden Seiten am Tag, sehen Sie sich an, wie unsere [Proxys für Data Scraping](/de/data-scraping) das zusätzliche Volumen tragen.
