---
title: "Einen Web-Crawler in Python bauen: Schritt für Schritt"
description: "Ein Web-Crawler in Python legt Links ab der Start-URL in eine Warteschlange und besucht jede Seite einmal. Im Code: URL-Normalisierung, Tiefenlimit und Scope."
url: https://proxynet.io/de/blog/python-web-crawler
date: 2026-09-25
author: "Acar Diveroli"
category: "Web Scraping, Anleitungen"
lang: de
---

# Einen Web-Crawler in Python bauen: Schritt für Schritt

Sie brauchen eine Liste aller Kategorie- und Produktseiten einer Buchhandlungs-Website, die keine Sitemap hat. Unser Testziel ist books.toscrape.com, eine Sandbox, die zum Üben von Scraping gebaut wurde. Ein Crawler, der jedem Link folgt, ohne sich zu merken, wo er schon war, wird hier nie fertig: Die meisten Links führen zu Seiten zurück, die er bereits kennt. In unserer Messung zeigten 3.515 der 4.098 Links auf den ersten 60 Seiten auf Adressen, die der Crawler schon kannte, und 524 neue Adressen warteten noch in der Warteschlange.

Im Folgenden bauen wir einen Crawler mit Requests und BeautifulSoup, ohne Framework: Warteschlange, Seen-Set, URL-Normalisierung, Scope, Tiefenlimit, robots.txt, Pausen und Ausgabe als JSON Lines. Was ein Crawler ist und wie er sich von einem Scraper unterscheidet, erklären unsere Seite zum [Web-Crawler](/de/web-crawler) und der Beitrag [Web Scraping vs. Web Crawling](/de/blog/web-scraping-vs-web-crawling). Alle Zahlen stammen aus Läufen des fertigen Skripts am 25. September 2026.

> **Hinweis: Kurzantwort**
>
> Für einen Web-Crawler in Python laden Sie jede Seite mit Requests herunter, holen die `a[href]`-Links mit BeautifulSoup, machen sie mit `urljoin` absolut und normalisieren sie. Links auf demselben Host kommen als `(url, depth)`-Paare in eine `collections.deque`. Wenn Sie eine Adresse schon beim Einreihen als gesehen markieren, wird keine Seite doppelt geladen; ein Tiefenlimit und eine Seitenobergrenze sorgen dafür, dass der Crawl endet. Prüfen Sie robots.txt einmal pro Host, legen Sie zwischen den Anfragen Pausen ein, halten Sie bei `429` an und schreiben Sie jede Seite als eine JSON-Zeile.

## Welche Bausteine braucht ein Web-Crawler in Python?

Sechs Bausteine, jeder davon ein paar Zeilen Python:

- **Frontier:** die Warteschlange der Adressen, die noch besucht werden müssen, eine `collections.deque`.
- **Seen-Set:** jede Adresse, auf die der Crawler bisher gestoßen ist.
- **Fetcher:** eine `requests.Session` mit Timeout und einem ehrlichen `User-Agent`, der den Bot und einen Kontakt nennt ([Was ist ein User-Agent?](/de/blog/what-is-user-agent)).
- **Link-Extraktor:** `select("a[href]")`. Den Rest der Seite zu lesen ist die Aufgabe eines Scrapers ([Was ist BeautifulSoup](/de/blog/beautifulsoup-tutorial)).
- **Scope-Filter:** Host, Schema und Content-Type entscheiden, was dazugehört.
- **Ausgabe:** ein JSON-Objekt pro Seite in einer `.jsonl`-Datei.

Wir verwenden Requests 2.34.2 und Beautiful Soup 4.15.0, die am 25. September 2026 aktuellen Versionen auf PyPI; Requests braucht Python 3.10 oder neuer. Scrapy bringt alle sechs Bausteine fertig mit.

## Wie läuft die Crawl-Schleife Schritt für Schritt ab?

1. **Seed.** Normalisieren Sie die Startadresse, legen Sie `(url, 0)` in die Warteschlange und die Adresse ins Seen-Set.
2. **Entnehmen.** `popleft()` holt den ältesten Eintrag.
3. **robots.txt fragen.** Die Regeln des Hosts kommen aus einem Cache und werden nur einmal abgerufen.
4. **Warten, dann abrufen.** Halten Sie die Pause pro Host ein und senden Sie dann eine GET-Anfrage.
5. **Antwort prüfen.** Lesen Sie die endgültige Adresse nach Weiterleitungen und den Header `Content-Type`.
6. **Extrahieren und filtern.** Lösen Sie jedes `href` mit `urljoin` auf, normalisieren Sie es und verwerfen Sie, was schon gesehen wurde, außerhalb der Website liegt oder zu tief ist.
7. **Speichern und einreihen.** Schreiben Sie eine JSON-Zeile und hängen Sie die neuen Adressen mit `depth + 1` an.

Die Schleife endet, wenn die Warteschlange leer ist oder die Seitenobergrenze erreicht ist.

## Welcher Baustein verhindert welches Problem?

| Baustein | Was er verhindert | In Python | Ohne ihn |
|---|---|---|---|
| Seen-Set, beim Einreihen gefüllt | Dieselbe Seite zweimal abrufen | `set`, beim Einreihen ergänzt | 3.515 bekannte Links in unserem books-Lauf erneut abgerufen |
| Tiefenlimit und Seitenobergrenze | Endlose Ketten wie Kalender und Filter | `(url, depth)`, `--depth`, `--max-pages` | Eine Warteschlange, die immer weiter wächst (524 wartend nach 60 Seiten) |
| URL-Normalisierung | Eine Seite unter mehreren Schreibweisen | `urldefrag`, `urlsplit`, `parse_qsl` | Dieselbe Seite wird mehr als einmal abgerufen und gespeichert |
| Prüfung von Weiterleitung und Content-Type | Abdriften auf eine andere Website, PDF als HTML parsen | `r.url`, `Content-Type`, `stream=True` | Links von fremden Seiten gelangen in die Warteschlange |
| robots.txt-Cache und Pause pro Host | Verbotene Pfade, Anfragespitzen | `RobotFileParser.parse()`, `time.monotonic()` | Die Website antwortet mit `429` |

## Warum sollte die Warteschlange eine deque sein und keine Liste?

`deque.popleft()` nimmt die älteste Adresse, der Crawl läuft also in die Breite (Breitensuche, BFS): erst die Startseite, dann die Seiten einen Klick entfernt, dann die zwei Klicks entfernten. Seiten nahe der Startseite kommen zuerst, und ein Tiefenlimit ergibt Sinn, weil die Tiefe der Reihe nach wächst. `pop()` an dem Ende, an das Sie anhängen, ergibt eine Tiefensuche (DFS): ein Zweig bis ganz nach unten, bevor der nächste drankommt.

Eine Liste kann als Warteschlange dienen, aber `pop(0)` verschiebt alle übrigen Elemente (O(n)), während eine deque an beiden Enden in annähernd konstanter Zeit entnimmt ([Dokumentation zu collections](https://docs.python.org/3/library/collections.html)). Rekursive Crawler sind verkappte Tiefensuchen, und sie können an Pythons Standardgrenze von 1.000 verschachtelten Aufrufen mit einem `RecursionError` abbrechen.

Bei der Breitensuche trifft der Crawler außerdem jede Adresse zuerst auf ihrer geringsten Tiefe. Ein Link, der schon beim ersten Auftauchen zu tief liegt, kann daher als gesehen markiert und vergessen werden.

## Wie schreiben Sie die URL-Normalisierung im Code?

Das Seen-Set vergleicht Zeichenketten, deshalb gibt `normalize()` jeder Seite genau eine Schreibweise:

- `urldefrag()` entfernt `#reviews`. Nach [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986.html), Abschnitt 3.5, erreicht das Fragment den Server nie.
- Schema und Host werden kleingeschrieben; `urlsplit().hostname` liefert den Host bereits in Kleinbuchstaben.
- Standardports (`:80` für http, `:443` für https) fallen weg, und ein leerer Pfad wird zu `/`, was Abschnitt 6.2.3 desselben RFC als gleichwertig behandelt.
- Tracking-Parameter (`utm_*`, `gclid`, `fbclid`) werden entfernt und die übrigen sortiert, sodass `?b=2&a=1` und `?a=1&b=2` übereinstimmen.
- Links mit `mailto:`, `tel:` und `javascript:` sowie kaputte Ports ergeben `None`.

Löschen Sie nie den ganzen Query-String: `?page=2` ist eine andere Seite. Auch der Schrägstrich am Ende bleibt stehen, denn `/a` und `/a/` können sich unterscheiden; ob sie es tun, verrät Ihnen eine Weiterleitung. Auf books.toscrape.com lieferten `/` und `/index.html` dieselbe Seite, was nur eine websitespezifische Regel zusammenführen könnte.

Das Seen-Set ignoriert außerdem das Schema. Auf quotes.toscrape.com leiten Autorenseiten von https auf http um, und ihre relativen Links zeigen danach auf `http://quotes.toscrape.com/`; unser erster Testlauf hat deshalb Startseite und Login-Seite zweimal abgerufen. `page_key()` lässt das Schema weg, sodass beide Schreibweisen als eine Seite zählen.

Die Sandboxes haben weder Fragmente noch Tracking-Parameter, daher arbeiten diese Prüfungen mit erfundenen Eingaben:

```python
from crawler import normalize

assert normalize("https://Example.COM:443/a#reviews") == "https://example.com/a"
assert normalize("http://example.com") == "http://example.com/"
assert normalize("https://example.com:8443/a") == "https://example.com:8443/a"
assert normalize("https://example.com/list?b=2&a=1") == "https://example.com/list?a=1&b=2"
assert normalize("https://example.com/list?utm_source=mail&page=2") == "https://example.com/list?page=2"
assert normalize("mailto:shop@example.com") is None
assert normalize("javascript:void(0)") is None
assert normalize("https://example.com:abc/") is None
assert normalize("https://example.com/a/") != normalize("https://example.com/a")
print("all normalize checks passed")
```

## Scope: derselbe Host, Weiterleitungen und Content-Type

Der Scope ist der Bereich, den der Crawler besuchen darf. `self.hosts` enthält nur den Start-Host. Nutzt eine Website sowohl `www.example.com` als auch `example.com`, tragen Sie beide von Hand ein; eine Prüfung wie `endswith("example.com")` würde auch `notexample.com` durchlassen.

Requests folgt Weiterleitungen bei jeder Methode außer HEAD und hält die endgültige Adresse in `r.url` fest ([Requests-Quickstart](https://requests.readthedocs.io/en/latest/user/quickstart/)). Prüfen Sie den Scope daher an `r.url` und lösen Sie relative Links gegen diese Adresse auf. Auf quotes.toscrape.com leitet `/author/Jane-Austen` auf `http://quotes.toscrape.com/author/Jane-Austen/` um; in unserem Lauf mit Tiefe 2 kamen 40 von 149 Seiten auf diesem Weg an. Eine Weiterleitung, die die Website verlässt, wird verworfen, doch Requests hat das Ziel da schon abgerufen. Wollen Sie auch diese Anfrage vermeiden, übergeben Sie `allow_redirects=False` und reihen den Header `Location` selbst ein.

Mit `stream=True` kehrt Requests zurück, sobald die Header angekommen sind. Ist der `Content-Type` nicht `text/html`, wird die Verbindung geschlossen, ohne den Body zu lesen.

## Wie legen Sie Tiefenlimit und Seitenobergrenze fest?

Die Einträge der Warteschlange sind `(url, depth, attempts)`. Ein Link, bei dem `depth + 1` über `--depth` hinausginge, zählt als `too_deep` und bleibt draußen; `--max-pages` beendet den Lauf, egal was noch in der Warteschlange steht. Auf den Sandboxes:

- **quotes.toscrape.com, Tiefe 2, Obergrenze 500:** Die Warteschlange leerte sich nach 149 Seiten von selbst (1, 46 und 102 nach Tiefe); 30 Adressen lagen zu tief.
- **Dieselbe Website, Obergrenze 60:** Der Lauf stoppte bei 60 Seiten, 89 Adressen standen noch in der Warteschlange.
- **books.toscrape.com, Tiefe 2, Obergrenze 60:** Alle 60 Seiten kamen aus den Tiefen 0 und 1, 524 warteten noch. Diesen Lauf hat die Obergrenze beendet, nicht die Tiefe.

Wählen Sie die Tiefe nach dem Aufbau der Website (Startseite, Kategorie, Artikel ergibt Tiefe 2; jede weitere Listenseite fügt eine Stufe hinzu) und die Obergrenze nach Ihrem Budget. Links, die gezielt als Falle für Bots ausgelegt werden, behandelt [Honeypot-Fallen beim Scraping](/de/blog/honeypot-traps).

## robots.txt und Pausen: die Kurzfassung

Der Crawler hält einen `RobotFileParser` pro Schema und Host, lädt robots.txt mit seiner eigenen Session herunter (Timeout, Bot-`User-Agent`, Proxy) und übergibt die Zeilen an `parse()`. `read()` vermeiden wir: In Python 3.13.9 nutzt es `urllib` ohne Timeout und mit dem eigenen `User-Agent` von urllib, und bei Netzwerkfehlern wirft es eine Ausnahme. Ein `5xx` oder eine nicht erreichbare Datei bedeutet, dass nichts gecrawlt wird, wie es [RFC 9309](https://www.rfc-editor.org/rfc/rfc9309.html) verlangt; ein `4xx` bedeutet, dass es keine Regeln gibt (beide Sandboxes lieferten `404`). Zur Syntax: [Was ist robots.txt](/de/blog/robots-txt).

Vor jeder Anfrage stellt der Crawler sicher, dass seit seiner letzten Anfrage an diesen Host `--delay` (standardmäßig 1 Sekunde) oder das `Crawl-delay` der Website vergangen ist, je nachdem, welcher Wert größer ist. Mit `Crawl-delay: 2` auf einer lokalen Testseite und `--delay 0.2` protokollierte der Server Abstände von 2 Sekunden. Bei einem `429` stoppt der Crawler den Host und gibt `Retry-After` aus. Wie man richtig wiederholt, steht in [HTTP-Statuscodes beim Web Scraping](/de/blog/http-status-codes-web-scraping), dieselben Prüfungen mit einer SQLite-Warteschlange in [Paginierung beim Web Scraping](/de/blog/pagination-web-scraping).

## Der vollständige Code: ein Web-Crawler in Python ohne Framework

Installieren Sie die beiden Bibliotheken in einer virtuellen Umgebung, speichern Sie das Skript als `crawler.py` und übergeben Sie ihm eine Startadresse:

```bash
pip install requests==2.34.2 beautifulsoup4==4.15.0
python crawler.py https://books.toscrape.com/ --depth 2 --max-pages 60
```

```python
"""Ein Breitensuche-Crawler für eine Website: Requests + BeautifulSoup, ohne Framework."""
import argparse
import json
import time
from collections import Counter, deque
from urllib.parse import parse_qsl, urldefrag, urlencode, urljoin, urlsplit, urlunsplit
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

BOT_NAME = "ExampleSiteMapper"
USER_AGENT = f"{BOT_NAME}/1.0 (+https://example.com/bot; bot@example.com)"
TIMEOUT = 15      # Sekunden; ohne Timeout kann Requests ewig warten
MAX_RETRIES = 2   # eine URL kommt höchstens zweimal ans Ende der Warteschlange zurück
TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "fbclid"}
DEFAULT_PORTS = {"http": 80, "https": 443}

def normalize(url):
    """Eine Schreibweise pro Seite, oder None für Links, denen ein Crawler nicht folgen soll."""
    url, _fragment = urldefrag(url.strip())
    parts = urlsplit(url)
    scheme = parts.scheme.lower()
    try:
        port = parts.port
    except ValueError:  # ein kaputter Port wie ":abc"
        return None
    if scheme not in DEFAULT_PORTS or not parts.hostname:
        return None  # mailto:, tel:, javascript:, ftp:, ...
    host = parts.hostname  # bereits kleingeschrieben
    if port and port != DEFAULT_PORTS[scheme]:
        host = f"{host}:{port}"
    query = sorted((k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
                   if k not in TRACKING)
    return urlunsplit((scheme, host, parts.path or "/", urlencode(query), ""))

def page_key(url):
    """Schlüssel im Seen-Set: http:// und https:// derselben Adresse zählen als eine Seite."""
    return url.split("://", 1)[1]

class Crawler:
    def __init__(self, start, max_depth, max_pages, delay, out, proxy=None):
        self.start = normalize(start)
        self.hosts = {urlsplit(self.start).hostname}  # einen "www."-Zwilling hier bewusst ergänzen
        self.max_depth, self.max_pages, self.delay, self.out = max_depth, max_pages, delay, out
        self.queue = deque([(self.start, 0, 0)])  # (url, depth, attempts)
        self.seen = {page_key(self.start)}  # markiert, wenn eine URL zum ersten Mal auftaucht, nicht beim Abruf
        self.robots, self.last, self.stopped = {}, {}, {}
        self.stats, self.depths = Counter(), Counter()
        self.session = requests.Session()
        self.session.headers["User-Agent"] = USER_AGENT
        # pro Anfrage: session.proxies würde gegen die Umgebungsvariablen HTTP(S)_PROXY verlieren
        self.proxies = {"http": proxy, "https": proxy} if proxy else None

    def in_scope(self, url):
        return urlsplit(url).hostname in self.hosts

    def fetch(self, url):
        """GET mit Pause pro Host. stream=True liest die Header vor dem Body."""
        parts = urlsplit(url)
        rules = self.robots.get(f"{parts.scheme}://{parts.netloc}")
        gap = max(self.delay, (rules.crawl_delay(BOT_NAME) if rules else None) or 0)
        pause = self.last.get(parts.netloc, float("-inf")) + gap - time.monotonic()
        if pause > 0:
            time.sleep(pause)
        try:
            return self.session.get(url, timeout=TIMEOUT, stream=True, proxies=self.proxies)
        finally:
            self.last[parts.netloc] = time.monotonic()  # pro Host: http und https teilen sich den Wert

    def robots_ok(self, url):
        root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
        if root not in self.robots:
            rules = RobotFileParser()
            try:
                with self.fetch(root + "/robots.txt") as r:
                    status = r.status_code
                    rules.parse(r.text.splitlines() if status == 200 else [])
            except requests.RequestException:
                status = None
                rules.parse([])
            if status is None or status >= 500:
                rules.disallow_all = True  # robots.txt nicht erreichbar: auf diesem Host nichts crawlen
            self.robots[root] = rules
        return self.robots[root].can_fetch(BOT_NAME, url)

    def extract_links(self, html, base):
        soup = BeautifulSoup(html, "html.parser")
        title = " ".join(soup.title.get_text().split()) if soup.title else ""
        return title, [urljoin(base, a["href"]) for a in soup.select("a[href]")]

    def enqueue(self, links, depth):
        for link in links:
            self.stats["raw_links"] += 1
            url = normalize(link)
            if url is None:
                self.stats["not_http"] += 1
                continue
            if page_key(url) in self.seen:
                self.stats["duplicate"] += 1
                continue
            self.seen.add(page_key(url))  # BFS trifft jede URL zuerst auf ihrer geringsten Tiefe
            if not self.in_scope(url):
                self.stats["offsite"] += 1
            elif depth + 1 > self.max_depth:
                self.stats["too_deep"] += 1
            else:
                self.queue.append((url, depth + 1, 0))
                self.stats["queued"] += 1

    def retry(self, url, depth, attempts, why):
        if attempts < MAX_RETRIES:
            self.queue.append((url, depth, attempts + 1))
            self.stats["retried"] += 1
        else:
            self.stats["failed"] += 1
            print(f"giving up on {url}: {why}")

    def run(self):
        started = time.monotonic()
        with open(self.out, "w", encoding="utf-8", newline="\n") as out:  # JSON Lines: \n, kein BOM
            while self.queue and self.stats["fetched"] < self.max_pages:
                url, depth, attempts = self.queue.popleft()
                host = urlsplit(url).netloc
                if host in self.stopped:
                    self.stats["skipped_stopped_host"] += 1
                    continue
                if not self.robots_ok(url):
                    self.stats["robots_disallowed"] += 1
                    continue
                try:
                    r = self.fetch(url)
                except requests.RequestException as exc:
                    self.retry(url, depth, attempts, type(exc).__name__)
                    continue
                with r:
                    if r.status_code == 429:
                        self.stopped[host] = r.headers.get("Retry-After", "not sent")
                        print(f"429 from {host}, host stopped (Retry-After: {self.stopped[host]})")
                        continue
                    if r.status_code >= 500:
                        self.retry(url, depth, attempts, f"HTTP {r.status_code}")
                        continue
                    final = normalize(r.url)
                    moved = page_key(final) != page_key(url)  # nicht nur http -> https
                    if not self.in_scope(final) or (moved and page_key(final) in self.seen):
                        self.stats["redirect_skipped"] += 1  # Website verlassen oder bekannte Seite
                        continue
                    is_html = "text/html" in r.headers.get("Content-Type", "")
                    try:  # erst hier wird der Body geladen, und nur bei HTML
                        html = r.content if r.status_code == 200 and is_html else b""
                    except requests.RequestException as exc:  # Verbindung mitten im Body abgebrochen
                        self.retry(url, depth, attempts, type(exc).__name__)
                        continue
                    if final != url:
                        self.stats["redirected"] += 1
                        self.seen.add(page_key(final))
                    self.stats["fetched"] += 1
                    self.depths[depth] += 1
                    title, links = "", []
                    if html:
                        self.stats["html_bytes"] += len(html)  # für eine Traffic-Schätzung
                        title, links = self.extract_links(html, r.url)
                    elif not is_html:
                        self.stats["not_html"] += 1  # Body nie heruntergeladen
                    record = {"url": url, "final_url": final, "depth": depth,
                              "status": r.status_code, "title": title, "links_found": len(links)}
                    out.write(json.dumps(record, ensure_ascii=False) + "\n")
                    print(f"{r.status_code} d{depth} {url}")
                    self.enqueue(links, depth)
        print(f"\n{self.stats['fetched']} pages in {time.monotonic() - started:.1f} s,"
              f" by depth {dict(sorted(self.depths.items()))}, left in queue {len(self.queue)}")
        for key, value in sorted(self.stats.items()):
            print(f"  {key:<21}{value}")

if __name__ == "__main__":
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("start", help="start URL, e.g. https://books.toscrape.com/")
    ap.add_argument("--depth", type=int, default=2, help="link hops from the start URL")
    ap.add_argument("--max-pages", type=int, default=100, help="hard ceiling on fetched pages")
    ap.add_argument("--delay", type=float, default=1.0, help="minimum seconds between requests to one host")
    ap.add_argument("--out", default="pages.jsonl")
    ap.add_argument("--proxy", help="e.g. http://user:pass@pr.proxynet.io:8000")
    args = ap.parse_args()
    Crawler(args.start, args.depth, args.max_pages, args.delay, args.out, args.proxy).run()
```

Drei Details sind wichtig, wenn Sie den Code ändern:

- **Wiederholungen kommen ans Ende der Warteschlange,** höchstens zweimal, nach einem Verbindungsfehler oder einem `5xx`. Eine lokale Testseite, die zweimal mit `500` antwortete, lieferte beim dritten Versuch `200`. Wiederholungen innerhalb von Requests: [Max Retries Exceeded With URL](/de/blog/max-retries-exceeded-with-url).
- **Die Datei ist reines JSON Lines.** `encoding="utf-8"` schreibt keine Byte-Order-Mark, und `newline="\n"` behält die Zeilenenden `\n`, die [JSON Lines](https://jsonlines.org/) verlangt; sonst schreibt Windows `\r\n`. `ensure_ascii=False` hält Text außerhalb von ASCII lesbar. Ein Titel mit türkischen Buchstaben kam unverändert zurück ([Encoding-Fehler in Python](/de/blog/python-unicode-encoding-errors)).
- **Nur GET.** Der Crawler öffnet `/login` wie jede andere Seite und sendet nie ein Formular ab.

### Was wir beim Ausführen gesehen haben

Wir haben Python 3.13.9, die Standardpause von einer Sekunde und Tiefe 2 verwendet. Auf quotes.toscrape.com mit einer Obergrenze von 500:

```text
149 pages in 179.1 s, by depth {0: 1, 1: 46, 2: 102}, left in queue 0
  duplicate            2916
  fetched              149
  html_bytes           722227
  offsite              2
  queued               148
  raw_links            3096
  redirected           40
  too_deep             30
```

Auf books.toscrape.com mit einer Obergrenze von 60:

```text
60 pages in 69.8 s, by depth {0: 1, 1: 59}, left in queue 524
  duplicate            3515
  fetched              60
  html_bytes           2047561
  queued               583
  raw_links            4098
```

Auf quotes zeigten 94 % der Links auf bekannte Adressen, und nur 2 verschiedene Adressen lagen außerhalb der Website. Zwei Ausgabezeilen, eine normale und eine weitergeleitete:

```json
{"url": "https://books.toscrape.com/", "final_url": "https://books.toscrape.com/", "depth": 0, "status": 200, "title": "All products | Books to Scrape - Sandbox", "links_found": 94}
{"url": "https://quotes.toscrape.com/author/Jane-Austen", "final_url": "http://quotes.toscrape.com/author/Jane-Austen/", "depth": 1, "status": 200, "title": "Quotes to Scrape", "links_found": 4}
```

Eine kleine lokale Website hat die Fehlerzweige abgedeckt. Der Crawler übersprang einen gesperrten Pfad, drei Links, die keine Webadressen waren, und eine Weiterleitung auf einen anderen Host, schloss ein PDF ungelesen und protokollierte ein `404`. Ein `429` mit `Retry-After: 120` stoppte den Host, und bei einem `503` auf robots.txt wurde nichts abgerufen.

## Wo wird der Proxy im Crawler eingebunden?

`--proxy http://user:pass@pr.proxynet.io:8000` wird mit jeder Anfrage übergeben, auch mit der für robots.txt. Der Code setzt `session.proxies` nicht, weil die Requests-Dokumentation warnt, dass Proxy-Umgebungsvariablen diesen Wert überschreiben ([Advanced Usage](https://requests.readthedocs.io/en/latest/user/advanced/#proxies)). Über einen lokalen Test-Proxy mit Passwort lieferten die ersten 10 books-Seiten dieselben Zeilen wie ohne Proxy, selbst mit einem toten Proxy in `HTTPS_PROXY`.

Eine einzelne Website bei einer Anfrage pro Sekunde braucht selten einen Proxy. Er hilft, wenn ein Crawl viele Hosts abdeckt und der Verkehr nicht komplett von einer Adresse ausgehen soll: Ein [Rotierender Proxy](https://proxynet.io/de/rotating-proxy) gibt jeder Anfrage oder jedem Host eine andere Ausgangs-IP. Für eine kurze, feste Liste von Zielen reicht oft ein [Datacenter-Proxy](https://proxynet.io/de/datacenter-proxy); seine IPs werden standardmäßig mit einer Beschränkung auf bestimmte Zielseiten geliefert, und der Zugriff auf alle Websites ist bei der Bestellung eine eigene Option. Code für die Rotation steht in [Proxys in Python rotieren](/de/blog/how-to-rotate-proxies-in-python).

Rotierender Residential-Traffic wird pro GB abgerechnet; messen Sie `html_bytes` deshalb zuerst bei einem kurzen Lauf. Beide Sandboxes lieferten unkomprimiertes HTML, etwa 34 KB pro books-Seite und 5 KB pro quotes-Seite: 10.000 Seiten ergeben grob 0,34 GB bzw. 0,05 GB, zuzüglich Header. Pause und robots.txt gelten für jede Ausgangs-IP, und Rotation ist kein Grund, einer Website mehr Anfragen zu schicken, als sie erlaubt.

## Anwendungsfälle

- **Defekte interne Links:** die Ausgabe nach Status `404` filtern ([Web-Crawler](/de/web-crawler)).
- **Eine URL-Liste vor dem Scraping:** erst crawlen, dann nur Produkt- oder Artikelseiten scrapen ([Data Scraping](/de/data-scraping)).
- **Neue Produkte bei Wettbewerbern:** die URL-Liste dieser Woche mit der der Vorwoche vergleichen ([Wettbewerberpreise im E-Commerce überwachen](/de/blog/competitor-price-tracking)).
- **Lücken in der Sitemap:** Seiten, die der Crawler findet, die aber in der Sitemap fehlen ([Die Sitemap einer Website finden](/de/blog/find-website-sitemap)).
- **Bilder von bekannten Seiten:** erst die Seiten auflisten, dann herunterladen ([Alle Bilder von einer Website herunterladen](/de/blog/download-all-images-from-website)).
- **Felder aus den gefundenen Seiten:** Titel und Preise aus demselben HTML ([Was ist BeautifulSoup](/de/blog/beautifulsoup-tutorial)).

## Häufige Fehler

- **`list.pop(0)` als Warteschlange.** Jeder Aufruf verschiebt die ganze Liste.
- **Rekursion für jeden Link.** Der Crawl wird zur Tiefensuche und kann mit `RecursionError` enden.
- **URLs erst nach dem Abruf als gesehen markieren.** Dieselbe Adresse landet viele Male in der Warteschlange.
- **Den ganzen Query-String löschen.** `?page=2` und alle folgenden Seiten verschwinden.
- **Links gegen die angefragte URL auflösen.** Nach einer Weiterleitung ist die Basis `r.url`.
- **Kein Timeout.** Ohne Timeout kann Requests unbegrenzt hängen.
- **PDF- und ZIP-Dateien als HTML parsen.** Prüfen Sie zuerst den `Content-Type`.
- **Keine Seitenobergrenze.** Auf einer Website mit Filtern oder Kalender endet der Lauf nie.
- **Formulare ausfüllen.** Ein Crawler liest Seiten per GET; Daten zu senden ist eine andere Aufgabe ([JSON mit Python Requests per POST senden](/de/blog/python-requests-post-json)).

## Entscheidungshilfe

| Bedarf | Empfehlung |
|---|---|
| Einige Hundert Seiten einer Website, mit dem Mechanismus im Blick | Das Skript aus diesem Beitrag |
| Einen langen Crawl nach einem Abbruch fortsetzen | Eine Warteschlange auf der Festplatte: die SQLite-Version in [Paginierung beim Web Scraping](/de/blog/pagination-web-scraping) |
| Viele Anfragen gleichzeitig | Nebenläufigkeit, dimensioniert wie in [Concurrency und Parallelism](/de/blog/concurrency-vs-parallelism) |
| Tausende Seiten, eingebaute Wiederholungen und Exporte | Scrapy ([CrawlSpider](/de/blog/web-scraping-vs-web-crawling), [Proxy-Einrichtung](/de/blog/scrapy-proxy)) |
| Links, die erst nach dem Ausführen von JavaScript erscheinen | Ein browserbasierter Crawler wie der PlaywrightCrawler von Crawlee for Python oder [Crawl4AI](/de/blog/crawl4ai-proxy) |
| Die Website veröffentlicht eine Sitemap | Zuerst die Sitemap lesen, nur für ihre Lücken crawlen ([Die Sitemap einer Website finden](/de/blog/find-website-sitemap)) |
| Ein Crawl über viele Hosts | Ein [Rotierender Proxy](https://proxynet.io/de/rotating-proxy) bei demselben rücksichtsvollen Tempo pro Host |

## Häufige Fragen

### Welche Python-Bibliothek eignet sich für einen Web-Crawler?

Für einige Hundert Seiten reichen Requests zum Herunterladen und BeautifulSoup zum Lesen der Links. Für Tausende Seiten mit Wiederholungen, Exporten und Zeitplanung spart Ihnen Scrapy diesen Code. Links, die erst JavaScript erzeugt, brauchen ein browserbasiertes Werkzeug.

### Reicht BeautifulSoup für einen Crawler, oder braucht man Scrapy?

BeautifulSoup reicht. Es ist ein Parser: Es liest Links aus HTML, lädt aber keine Seiten herunter, führt keine Warteschlange und wartet nicht zwischen Anfragen. Das Skript oben erledigt diese Aufgaben in weniger als 200 Zeilen. Scrapy hat sie eingebaut, was sich bei größeren Aufträgen auszahlt.

### Sieht ein Python-Crawler Links, die per JavaScript geladen werden?

Nein. Requests führt keine Skripte aus, daher kommen per JavaScript hinzugefügte Links nie bei BeautifulSoup an. Suchen Sie zuerst nach einer JSON-Anfrage hinter der Seite, die Sie direkt aufrufen können. Gibt es keine, nutzen Sie einen browserbasierten Crawler, sofern die Nutzungsbedingungen der Website Automatisierung erlauben.

### Sollte der Crawler mit mehreren Threads arbeiten?

Nur wenn Sie mehrere Hosts crawlen. Auf einer einzelnen Website bestimmt die Pause zwischen den Anfragen das Tempo, und zusätzliche Threads würden nur warten oder die Pause unterlaufen. Über viele Hosts hinweg hilft ein rücksichtsvoller Worker pro Host ([Concurrency und Parallelism](/de/blog/concurrency-vs-parallelism)).

### Wie finden Sie mit Python alle Links einer Website?

Beginnen Sie mit der [Sitemap](/de/blog/find-website-sitemap), die meist in robots.txt genannt wird oder unter `/sitemap.xml` liegt. Gibt es keine, lassen Sie einen Crawler wie diesen mit Tiefenlimit und Seitenobergrenze laufen und betrachten Sie das Ergebnis als das, was über Links erreichbar ist, nicht als vollständige Liste.

### Ist es legal, einen Web-Crawler zu schreiben und zu betreiben?

Dies ist keine Rechtsberatung. Die Antwort hängt von Ihrem Land ab, von den Nutzungsbedingungen der Website, davon, ob die Seiten personenbezogene Daten enthalten, und davon, was Sie mit den Kopien machen. Befolgen Sie robots.txt, halten Sie das Tempo niedrig und bleiben Sie aus Bereichen hinter einem Login heraus. Land für Land: [Ist Web Scraping legal?](/de/blog/is-data-web-scraping-legal).

## Fazit

Ein Crawler, der zu Ende kommt, ohne sich zu wiederholen, braucht eine deque für die Reihenfolge der Breitensuche, ein Seen-Set, das beim Einreihen gefüllt wird, eine Schreibweise pro URL, eine Scope-Prüfung nach Weiterleitungen sowie ein Tiefenlimit mit Seitenobergrenze. robots.txt, eine Pause pro Host und ein Stopp bei `429` gehören schon in die erste Version. Wächst die Aufgabe, wechseln Sie zu einer Warteschlange auf der Festplatte, zu Nebenläufigkeit über mehrere Hosts oder zu Scrapy. Für Crawls über viele Websites oder Länder vergleichen Sie die Optionen auf unserer Seite [Proxy-Dienste](/de/proxy).
