Das Skript, mit dem Sie einen Katalog von tausend Produkten abrufen wollen, liest die zwanzig Produkte der ersten Seite ohne Probleme. Die eigentliche Arbeit beginnt danach: die Adressen der restlichen 49 Seiten finden, erkennen, dass die Liste zu Ende ist, dasselbe Produkt nicht zweimal speichern und nicht von vorn anfangen, wenn auf Seite 31 die Verbindung abbricht. Der Unterschied zwischen Code, der eine Seite liest, und Code, der die ganze Liste vollständig einsammelt, heißt Paginierung.
In diesem Beitrag definieren wir Paginierung kurz und schauen dann aus der Sicht der crawlenden Seite darauf: Wie erkennt man die fünf Arten in den Entwicklertools, woher erfährt man die nächste Seite, wann hört der Crawl auf, wie sortiert man doppelte Datensätze aus. Im Zentrum steht eine URL-Warteschlange auf SQLite; ein Crawl, den wir mitten im Lauf abgeschossen haben, lief dank dieser Warteschlange auf der Seite weiter, auf der er stehen geblieben war. Den Code haben wir auf den Übungsseiten books.toscrape.com und quotes.toscrape.com ausgeführt.
Was ist Paginierung?
Wenn eine Datenbank zehntausend Datensätze enthält, schickt der Server sie nicht alle in einer Antwort. Die Abfrage wird schwer, und die Antwort bläht sich mit Tausenden Zeilen auf, die niemand ansieht. Stattdessen wird die Liste in gleich große Stücke geteilt, und der Client fragt jeweils ein Stück ab. Die Links „1 2 3 ... 50" unten auf einer Kategorieseite, der Parameter ?page=2 einer API und Feeds, die beim Scrollen nachladen, sind verschiedene Gesichter derselben Idee.
Wer Paginierung entwirft, fragt: „Welches Verfahren belastet die Datenbank weniger?" Die crawlende Seite stellt eine andere Frage: Welches Verfahren hat diese Website gewählt, und wie erkenne ich das von außen? Den allgemeinen Rahmen der Massendatenerfassung finden Sie auf unserer Seite zum Data Scraping, den Aufbau von Crawlern, die von Link zu Link laufen, auf der Seite Web Crawler.
Welche Arten der Paginierung gibt es und wie erkennt man sie?
Der Weg zur Erkennung ist bei jeder Art derselbe: Öffnen Sie die Entwicklertools im Browser, leeren Sie den Netzwerk-Tab, wechseln Sie auf die zweite Seite und schauen Sie, was sich geändert hat. Hat sich die Adresszeile geändert, oder ist im Hintergrund eine Anfrage hinausgegangen?
1. URL mit Seitennummer. Die Adresse ändert sich zu ?page=2, /page/2/ oder page-2.html. Das ist die am leichtesten erkennbare Art, und die Schleife ist einfach: Sie erhöhen die Zahl. Ihre Schwäche ist, dass Sie meistens nicht wissen, welches die letzte Seite ist.
2. „Weiter"-Link. Unten auf der Seite steht ein <a>-Element, das auf die nächste Seite zeigt. Sie erzeugen die Adresse nicht, Sie lesen sie aus der Seite. Fehlt der Link, ist die Liste zu Ende. Ihr Code bricht nicht, wenn die Website ihre Adressstruktur ändert; bei HTML-Listen ist das die erste Wahl. Die Logik der Selektoren haben wir im Beitrag CSS-Selektor und XPath beschrieben.
3. offset/limit. In der API-Anfrage sehen Sie zwei Parameter wie offset=40&limit=20 oder skip und take: „Überspringe die ersten 40 Datensätze, gib mir 20." Das ist das API-Gegenstück zur Seitennummer.
4. Cursor. In der Antwort kommt eine sinnlos aussehende Zeichenkette wie next_cursor, after oder next_page_token, und Sie schicken diese Zeichenkette in der nächsten Anfrage unverändert zurück. Der Cursor trägt die Information „zuletzt habe ich diesen Datensatz geliefert"; Sie versuchen nicht, ihn zu entschlüsseln, Sie reichen ihn nur weiter.
5. Endlosscrollen und „Mehr laden". Die Adresszeile ändert sich nicht. Wenn Sie ans Seitenende kommen oder den Button drücken, taucht im Netzwerk-Tab eine neue Fetch/XHR-Anfrage auf. Sehen Sie sich diese Anfrage an, finden Sie meistens eine der drei Arten von oben. Endlosscrollen ist kein eigenes Verfahren, sondern eine Oberfläche über der API-Paginierung.
Dazu kommt noch die Markierung rel="next". Sie begegnet Ihnen an zwei Stellen. Die erste ist das Element <link rel="next" href="..."> im <head> des HTML. Google schreibt ausdrücklich, dass es dieses Tag nicht mehr verwendet, aber viele Websites geben es weiterhin aus, und wenn Sie es finden, ist es eine saubere Kopie des „Weiter"-Links. Die zweite ist der Link-Header in der HTTP-Antwort; sein Format definiert RFC 8288, und Dienste wie GitHub liefern in diesem Header die vollständige Adresse der nächsten Seite.
| Art | Woran man sie erkennt | Woher die nächste Seite kommt | Abbruchbedingung | Fallstrick |
|---|---|---|---|---|
| Seitennummer | page=2, /page/2/ in der Adresse | Sie erhöhen die Zahl | 404, leere Liste oder wiederholter Inhalt | Was nach der letzten Seite passiert, ist je nach Website anders |
| „Weiter"-Link | <a> unten auf der Seite, rel="next" im <head> | Wird aus der Seite gelesen | Kein Link | Vergessen, die relative Adresse in eine absolute zu wandeln |
| offset/limit | offset, limit, skip in der API-Anfrage | offset += limit | Unvollständiges oder leeres Stück | Datensätze verschieben sich, während sich die Liste ändert |
| Cursor | next_cursor, after, ein Token in der Antwort | Wird aus der Antwort übernommen | Cursor leer oder nicht vorhanden | Der Cursor läuft ab, aus der Mitte kann man nicht starten |
| Endlosscrollen, „Mehr laden" | Adresse ändert sich nicht, es geht eine XHR-Anfrage hinaus | Die Regel der darunterliegenden API | Die Regel der API oder das Ausbleiben neuer Karten | Mit dem Browser zu scrollen ist unnötig teuer |
Aus welchen Schritten besteht eine Paginierungsschleife?
Welche Art es auch ist, die Schleife folgt denselben sechs Schritten:
- Legen Sie die Startadresse in die Warteschlange. Die Kategorieseite oder die erste Anfrage der API.
- Nehmen Sie die nächste Adresse und prüfen Sie, ob sie erlaubt ist. Verbietet
robots.txtdiesen Pfad, geht die Anfrage gar nicht erst hinaus. - Warten Sie, dann senden Sie die Anfrage. Legen Sie zwischen zwei Anfragen an dieselbe Website einen festen Abstand.
- Lesen Sie die Datensätze aus. Geben Sie jedem Datensatz einen Schlüssel, der ihn eindeutig bezeichnet.
- Finden Sie die nächste Seite. Link, Zahl, Offset oder Cursor.
- Schreiben Sie die Datensätze, die nächste Adresse und die Markierung „diese Seite ist fertig" gemeinsam. Danach zurück zu Schritt zwei.
Das Wort „gemeinsam" im sechsten Schritt ist das Thema des restlichen Beitrags. Zuerst zur Abbruchbedingung.
Wann sollte ein Crawl aufhören?
Zu erkennen, dass die Liste zu Ende ist, ist schwerer als es aussieht, denn Websites verhalten sich nach der letzten Seite unterschiedlich. Auf den Übungsseiten haben wir drei verschiedene Verhalten nebeneinander gesehen. books.toscrape.com hat 50 Seiten, die Anfrage page-51.html liefert 404. quotes.toscrape.com/page/11/ dagegen liefert mit dem Code 200 eine Seite, auf der kein einziges Zitat steht. Die JSON-API derselben Website schreibt auf der letzten Seite "has_next": false, und wenn Sie Seite 11 anfordern, bekommen Sie eine leere Liste. Auf echten Websites ist ein viertes Verhalten häufiger: bei einer Seitennummer außerhalb des Bereichs stillschweigend wieder die erste oder die letzte Seite zeigen.
Verlassen Sie sich deshalb nicht auf eine einzige Bedingung, sondern nutzen Sie mehrere zusammen:
- Ein deutliches Signal: kein „Weiter"-Link,
has_nextist falsch, der Cursor ist leer, imLink-Header steht keinrel="next". - Leeres oder unvollständiges Stück: auf der Seite steht kein Datensatz, oder es kommen weniger Datensätze als der Wert von
limit. - Keine neuen Datensätze: alle Datensätze der Seite wurden schon gesehen. Das ist die Bedingung, die Websites erwischt, die immer wieder die letzte Seite zeigen.
- Obergrenze: eine Seitenzahl, die unter keinen Umständen überschritten wird. Ein kaputter „Weiter"-Link oder ein sich wiederholender Cursor kann Ihr Skript so nicht in eine Endlosschleife schicken.
- Gesamtzahl: Liefert die API
totalodertotal_pages, nutzen Sie das zum Prüfen, nicht zum Abbrechen.
Beim Vorgehen über Seitennummern kann ein 404 sowohl „die Liste ist zu Ende" als auch „die Adressstruktur hat sich geändert" bedeuten. Bekommen Sie schon auf der ersten Seite 404, ist es das Zweite.
Wie sortiert man doppelte Datensätze aus?
Dass derselbe Datensatz während der Paginierung zweimal kommt, ist kein Fehler, sondern zu erwarten. Der häufigste Grund ist, dass sich die Liste ändert, während Sie sie crawlen. Wenn in einer nach „Neueste" sortierten Liste genau in dem Moment, in dem Sie die dritte Seite lesen, fünf neue Produkte oben eingefügt werden, rutschen alle Datensätze fünf Plätze nach unten, und die ersten fünf Datensätze der vierten Seite sind die, die Sie eben gesehen haben. Wird ein Datensatz gelöscht, passiert das Gegenteil: Ein Datensatz rutscht nach oben und Sie sehen ihn nie. offset/limit und Seitennummern sind für diese Verschiebung anfällig; der Cursor nicht, weil er „die nach diesem Datensatz" sagt. Auch gesponserte Produkte und Produkte in zwei Kategorien erzeugen Dubletten.
Die Lösung besteht darin, Dubletten nicht im Code, sondern in der Datenbank auszusortieren:
- Geben Sie jedem Datensatz einen stabilen Schlüssel. Die Produkt-ID, die Produktadresse oder das Feld
idin der API. Gibt es nichts davon, bilden Sie aus den unveränderlichen Feldern des Datensatzes einen Hash. Die Position in der Liste taugt nicht als Schlüssel. - Machen Sie den Schlüssel zum Primärschlüssel und fügen Sie mit
INSERT OR IGNOREein. Kommt derselbe Schlüssel ein zweites Mal, überspringt SQLite die Zeile stillschweigend. Dieses Verhalten ist in der Dokumentation zu den Konfliktregeln von SQLite festgelegt. Wollen Sie ein veränderliches Feld wie den Preis aktualisieren, nehmen SieON CONFLICT ... DO UPDATE. - Legen Sie die Sortierung fest. Bietet die Website eine Sortieroption, wählen Sie ein Feld, das sich nicht ändert (nach Name oder ID statt nach Hinzufügedatum). Die Verschiebung wird kleiner.
API-Paginierung: offset, Cursor und der Link-Header
Die Schleifen der drei API-Arten ähneln einander stark; der Unterschied liegt darin, wie die nächste Anfrage gebaut wird. Die drei Funktionen unten liefern die Datensätze einzeln (yield), sodass der aufrufende Code nicht wissen muss, um welche Art von Paginierung es sich handelt. Die Feldnamen (items, next_cursor) unterscheiden sich von API zu API; sehen Sie in die Dokumentation Ihres Ziels.
import time
import requests
def crawl_offset(session, url, limit=100, max_pages=500, delay=1.0):
"""offset/limit: hält an, sobald eine unvollständige oder leere Seite kommt."""
offset = 0
for _ in range(max_pages):
response = session.get(url, params={"offset": offset, "limit": limit}, timeout=20)
response.raise_for_status()
batch = response.json()["items"]
yield from batch
if len(batch) < limit:
break
offset += limit
time.sleep(delay)
def crawl_cursor(session, url, max_pages=500, delay=1.0):
"""cursor: der Cursor aus der Antwort wird unveraendert in die naechste Anfrage uebernommen."""
cursor, seen = None, set()
for _ in range(max_pages):
response = session.get(url, params={"cursor": cursor} if cursor else {}, timeout=20)
response.raise_for_status()
payload = response.json()
yield from payload["items"]
cursor = payload.get("next_cursor")
if not cursor or cursor in seen: # kein Cursor oder er wiederholt sich
break
seen.add(cursor)
time.sleep(delay)
def crawl_link_header(session, url, max_pages=500, delay=1.0):
"""Link-Header: die rel="next"-Adresse kommt fertig, nichts wird berechnet."""
for _ in range(max_pages):
response = session.get(url, timeout=20)
response.raise_for_status()
yield from response.json()
url = response.links.get("next", {}).get("url")
if not url:
break
time.sleep(delay)Wir haben alle drei an einer lokalen Schein-API mit 250 Datensätzen erprobt: Jede sammelte die 250 Datensätze in drei Anfragen, ohne Dubletten. An einem kaputten Endpunkt, der immer denselben Cursor zurückgab, hielt crawl_cursor nach der zweiten Anfrage an; ohne die Menge seen hätte es 500 Anfragen geschickt. Die Funktion crawl_link_header haben wir außerdem an der Tag-Liste eines öffentlichen GitHub-Repositorys laufen lassen und auf zwei Seiten 200 Datensätze erhalten. Requests zerlegt den Link-Header selbst in das Dictionary response.links. Die Paginierungsdokumentation von GitHub empfiehlt dasselbe: Bauen Sie die Adresse nicht von Hand, folgen Sie der rel="next"-Adresse.
Wie crawlt man Endlosscrollen und den Button „Mehr laden"?
Der erste Schritt ist nicht die Browserautomatisierung, sondern der Netzwerk-Tab. Die Seite quotes.toscrape.com/scroll ist ein gutes Beispiel: Beim Scrollen kommen neue Zitate, und jedes Mal geht eine Anfrage an /api/quotes?page=2, page=3 hinaus. Die Antwort ist JSON und enthält das Feld has_next. Diese Anfrage direkt aufzurufen ist schneller als Scrollen und belastet die Website weniger; Bilder, Schriften und Skripte werden gar nicht geladen. Das Warteschlangenbeispiel unten holt die Zitate auf diesem Weg. Wie man die Anfrage findet, steht im Abschnitt „Zuerst die API/XHR-Anfrage finden" unseres Beitrags Statische und dynamische Seiten.
Lässt sich die Anfrage nicht wiederholen (sie trägt einen signierten Parameter, oder die Antwort kommt als HTML-Fragment und wird vom Skript der Seite verarbeitet), wechseln Sie zur Browserautomatisierung. Die Abbruchbedingung lautet dort „die Kartenzahl wächst nicht mehr":
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://quotes.toscrape.com/scroll")
page.wait_for_selector(".quote")
count, idle = 0, 0
while idle < 3 and count < 500: # anhalten, wenn drei Runden nichts Neues kommt oder die Obergrenze erreicht ist
page.mouse.wheel(0, 10000)
page.wait_for_timeout(1000)
current = page.locator(".quote").count()
idle = idle + 1 if current == count else 0
count = current
print(count, "Karten geladen")
browser.close()Dieses Skript hat auf der Übungsseite alle 100 Karten geladen. Beim Button „Mehr laden" ist die Schleife dieselbe, Sie klicken nur den Button statt zu scrollen und halten auch an, wenn der Button von der Seite verschwindet. In Selenium erledigt derselbe Aufruf execute_script("window.scrollTo(0, document.body.scrollHeight)") die Arbeit, zusammen mit dem erneuten Zählen der Karten; die Unterschiede der beiden Werkzeuge stehen im Beitrag Playwright und Selenium. Das Scrollen hat eine Grenze: Bei langen Feeds hält die Seite Tausende Elemente im Speicher und wird langsam. Brauchen Sie mehr als ein paar Hundert Karten, lohnt die Suche nach der API-Anfrage.
Wie setzt man einen abgebrochenen Crawl fort: die SQLite-URL-Warteschlange
Bei kurzen Listen genügt es, die nächste Adresse in einer Variablen zu halten; die kategoriecrawlende Funktion in unserem Beitrag Wettbewerberpreise im E-Commerce überwachen arbeitet so, und für eine Kategorie mit zwei Seiten ist das die richtige Wahl. Sobald die Liste auf Hunderte Seiten wächst, reicht eine Variable nicht mehr: Die Verbindung bricht ab, der Rechner geht in den Ruhezustand, der Server startet neu, und die Adresse in der Variablen verschwindet mit dem Prozess. Sie müssen auf die Festplatte schreiben, wo Sie stehen geblieben sind.
Dafür brauchen Sie keinen eigenen Warteschlangenserver. Das mit Python gelieferte SQLite erledigt die Arbeit mit zwei Tabellen: queue hält die zu crawlenden Adressen und ihren Status (pending, done, failed, blocked), items die gesammelten Datensätze. Der Kniff ist eine einzige Regel: Die Datensätze einer Seite, die von dieser Seite gelernte nächste Adresse und die Markierung done der Seite werden in derselben Datenbanktransaktion geschrieben. Die Transaktion geht entweder ganz auf die Festplatte oder gar nicht. Stirbt der Prozess genau in diesem Moment, bleibt die Seite in der Warteschlange pending und wird beim nächsten Lauf erneut gelesen. Eine Seite, die „fertig" aussieht, deren Datensätze aber unvollständig sind, kann nicht entstehen.
Das folgende Skript crawlt zwei verschiedene Paginierungsarten in derselben Warteschlange: den Buchkatalog über den „Weiter"-Link, die Zitate über die JSON-API hinter dem Endlosscrollen.
import hashlib
import json
import sqlite3
import time
from urllib.parse import urljoin, urlsplit
from urllib.robotparser import RobotFileParser
import requests
from bs4 import BeautifulSoup
DB_PATH = "crawl.db"
BOT_NAME = "ExampleCrawler"
USER_AGENT = f"{BOT_NAME}/1.0 (+https://example.com/bot)"
PROXY = None # Beispiel: "http://user:pass@pr.proxynet.io:8000"
DELAY = 1.0 # kuerzester Abstand zwischen zwei Anfragen an dieselbe Website (Sekunden)
MAX_PAGES = 200 # Obergrenze gegen eine kaputte "Weiter"-Kette
MAX_ATTEMPTS = 3
SEEDS = [
("https://books.toscrape.com/", "books"),
("https://quotes.toscrape.com/api/quotes?page=1", "quotes"),
]
SCHEMA = """
CREATE TABLE IF NOT EXISTS queue (
url TEXT PRIMARY KEY,
kind TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | done | failed | blocked
attempts INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS items (
item_key TEXT PRIMARY KEY,
kind TEXT NOT NULL,
data TEXT NOT NULL,
page_url TEXT NOT NULL
);
"""
def parse_books(response):
"""HTML-Liste: Datensaetze aus den Karten, naechste Seite aus dem 'next'-Link."""
soup = BeautifulSoup(response.content, "html.parser")
items = []
for card in soup.select("article.product_pod"):
link = card.select_one("h3 a")
url = urljoin(response.url, link["href"])
items.append((url, {"title": link["title"], "price": card.select_one(".price_color").text}))
next_link = soup.select_one("li.next a")
return items, urljoin(response.url, next_link["href"]) if next_link else None
def parse_quotes(response):
"""JSON-API (die Anfrage hinter dem Endlosscrollen): Schluss, wenn has_next endet."""
payload = response.json()
items = []
for quote in payload["quotes"]:
key = hashlib.sha1(quote["text"].encode("utf-8")).hexdigest()
items.append((key, {"text": quote["text"], "author": quote["author"]["name"]}))
next_url = None
if payload["has_next"]:
next_url = urljoin(response.url, f"?page={payload['page'] + 1}")
return items, next_url
PARSERS = {"books": parse_books, "quotes": parse_quotes}
robots_cache = {}
last_request = {}
def allowed(session, url):
"""Liest robots.txt einmal pro Website. 4xx: keine Einschraenkung, 5xx: kein Pfad wird gecrawlt (RFC 9309)."""
root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
if root not in robots_cache:
parser = RobotFileParser()
response = session.get(root + "/robots.txt", timeout=20)
if response.status_code >= 500:
parser.disallow_all = True
elif response.status_code >= 400:
parser.allow_all = True
else:
parser.parse(response.text.splitlines())
robots_cache[root] = parser
return robots_cache[root].can_fetch(BOT_NAME, url)
def polite_get(session, url):
"""Fragt dieselbe Website nicht haeufiger als DELAY (oder das Crawl-delay aus robots.txt) an."""
host = urlsplit(url).netloc
root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
delay = max(DELAY, float(robots_cache[root].crawl_delay(BOT_NAME) or 0))
wait = last_request.get(host, 0) + delay - time.monotonic()
if wait > 0:
time.sleep(wait)
try:
return session.get(url, timeout=20)
finally:
last_request[host] = time.monotonic()
def crawl():
db = sqlite3.connect(DB_PATH)
db.executescript(SCHEMA)
with db: # fuegt beim ersten Lauf die Startadressen ein, spaeter nicht mehr
db.executemany("INSERT OR IGNORE INTO queue (url, kind) VALUES (?, ?)", SEEDS)
session = requests.Session()
session.headers["User-Agent"] = USER_AGENT
if PROXY:
session.proxies = {"http": PROXY, "https": PROXY}
for _ in range(MAX_PAGES):
row = db.execute(
"SELECT url, kind FROM queue WHERE status = 'pending' ORDER BY attempts, rowid LIMIT 1"
).fetchone()
if row is None:
break # Warteschlange leer: der Crawl ist fertig
url, kind = row
if not allowed(session, url):
with db:
db.execute("UPDATE queue SET status = 'blocked' WHERE url = ?", (url,))
continue
try:
response = polite_get(session, url)
response.raise_for_status()
items, next_url = PARSERS[kind](response)
except (requests.RequestException, KeyError, ValueError) as exc:
with db:
db.execute(
"UPDATE queue SET attempts = attempts + 1,"
" status = CASE WHEN attempts + 1 >= ? THEN 'failed' ELSE 'pending' END"
" WHERE url = ?",
(MAX_ATTEMPTS, url),
)
print(f"FEHLER {url}: {exc}")
continue
# Datensaetze, naechste Adresse und die Markierung "Seite fertig" werden in einer Transaktion geschrieben.
# Stirbt der Prozess mitten in diesem Block, wird nichts davon geschrieben, die Seite bleibt 'pending'.
with db:
before = db.total_changes
db.executemany(
"INSERT OR IGNORE INTO items (item_key, kind, data, page_url) VALUES (?, ?, ?, ?)",
[(key, kind, json.dumps(data, ensure_ascii=False), url) for key, data in items],
)
new_items = db.total_changes - before
# Abbruchbedingung: leere Seite oder lauter schon gesehene Datensaetze
if next_url and items and new_items:
db.execute("INSERT OR IGNORE INTO queue (url, kind) VALUES (?, ?)", (next_url, kind))
db.execute("UPDATE queue SET status = 'done' WHERE url = ?", (url,))
print(f"OK {url}: {len(items)} Datensaetze, {new_items} neu")
for kind, status, count in db.execute(
"SELECT kind, status, COUNT(*) FROM queue GROUP BY kind, status ORDER BY kind, status"
):
print(f"Queue {kind:7} {status:8} {count}")
for kind, count in db.execute("SELECT kind, COUNT(*) FROM items GROUP BY kind ORDER BY kind"):
print(f"Datensatz {kind:7} {count}")
db.close()
if __name__ == "__main__":
crawl()Zum Ausführen des Skripts genügt pip install requests beautifulsoup4. Das Fortsetzen haben wir so geprüft: Wir haben das Skript gestartet und den Prozess in der 14. Sekunde direkt abgeschossen (nicht mit Strg+C). In diesem Moment lagen in der Datenbank 20 abgeschlossene Seiten, 200 Bücher, 100 Zitate und eine einzige Adresse im Status pending: page-11.html. Ohne irgendetwas zu ändern haben wir neu gestartet; der Crawl lief mit page-11.html weiter und war in weniger als einer Minute mit 50 Katalogseiten und 1.000 Büchern fertig. An die Zitate-API ging keine einzige Anfrage, alle zehn Seiten waren done. Der dritte Lauf fand keine wartende Adresse und schrieb nur die Zusammenfassung.
Worauf im Code zu achten ist:
- Der Block
with db:ist die Grenze der Transaktion. Wird die Verbindung im Modulsqlite3von Python als Kontextmanager verwendet, wird die Transaktion bestätigt, wenn der Block fehlerfrei endet, und zurückgenommen, wenn eine Ausnahme auftritt. Die Einzelheiten stehen in der Dokumentation des Moduls. - Der Primärschlüssel der Warteschlange ist die Adresse selbst. Wird dieselbe Adresse ein zweites Mal entdeckt, überspringt
INSERT OR IGNOREsie; so lösen sich zyklische Links auf. - Die Bedingung „keine neuen Datensätze" kollidiert nicht mit dem Fortsetzen. Weil die Datensätze einer abgebrochenen Seite nie geschrieben wurden, sind beim erneuten Lesen alle neu und die Kette reißt nicht.
- Eine fehlerhafte Seite rutscht ans Ende der Warteschlange. Dank
ORDER BY attemptswerden zuerst nie versuchte Adressen genommen; eine Seite, die in drei Versuchen nicht gelesen werden kann, wirdfailed, und der Crawl läuft ohne sie weiter. Der Zähler ist absichtlich schlicht: Der Wiederholungscode, der entscheidet, bei welchem Statuscode gewartet und bei welchem abgebrochen wird, und der den HeaderRetry-Afterauswertet, steht fertig in unserem Beitrag HTTP-Statuscodes beim Scraping; Sie können ihn an die Stelle vonpolite_getsetzen. - Eine neue Website hinzuzufügen heißt, einen Parser zu schreiben. Sie schreiben eine Funktion, die die Datensatzliste und die nächste Adresse zurückgibt, und tragen sie in das Dictionary
PARSERSein; Warteschlange, Wartezeit und Fortsetzungslogik ändern sich nicht.
Diese Warteschlange ist für einen einzigen Prozess gedacht. Sollen mehrere Worker aus derselben Warteschlange lesen, muss der Worker, der eine Adresse nimmt, sie in einen Zwischenstatus wie claimed ziehen, und dieser Status muss nach einer Zeitüberschreitung wieder auf pending fallen. Wann gleichzeitiges Crawlen wirklich Tempo bringt, steht in unserem Beitrag Nebenläufigkeit und Parallelität. Wächst die Zahl der Worker, macht ein fertiges Framework weniger Arbeit: Scrapy bringt Warteschlange, Dublettenfilter und Fortsetzen von Haus aus mit.
robots.txt, Ratenbegrenzung und Proxys
Paginierung ist die Arbeit, bei der Sie die meisten Anfragen hintereinander an eine Website schicken. Die Funktion allowed im Skript liest die robots.txt jeder Website einmal und fragt sie zu jeder Adresse. Die Fälle, in denen die Datei nicht gefunden wird, regelt RFC 9309: Eine 4xx-Antwort gilt als „keine Datei, keine Einschränkung", bei einer 5xx-Antwort muss der Crawler alle Pfade als verboten betrachten. Auf beiden Übungsseiten lieferte die Anfrage 404. Dass wir die Datei mit session holen, liegt daran, dass die Anfrage so mit dem User-Agent des Skripts und gegebenenfalls über den Proxy hinausgeht. urllib.robotparser unterstützt keine Platzhalter (*, $); die Grenzen stehen in unserem Beitrag Was ist die Datei robots.txt und wie liest man sie?.
polite_get legt zwischen zwei Anfragen an dieselbe Website mindestens DELAY Sekunden, und wenn auf der Website ein Crawl-delay steht, nimmt es dieses als Grundlage; die Wartezeit wird pro Website geführt. Sehen Sie 429, ist die richtige Reaktion nicht, die IP zu wechseln, sondern den Wert von DELAY zu erhöhen; die Gründe stehen in unserem Beitrag 429 Too Many Requests. Einen User-Agent zu schreiben, der Ihren Bot vorstellt, gehört ebenfalls dazu: Was ist ein User-Agent?.
Proxys kommen an zwei Stellen ins Bild. Die erste ist der Standort: Um den Katalog und die Preise zu sehen, die einer Besucherin in Türkiye gezeigt werden, muss die Anfrage aus Türkiye kommen. Die zweite ist die Lastverteilung: Bei langen Crawls über mehrere Websites hinweg werden Rotierender Proxy eingesetzt, damit sich der Verkehr nicht auf einer einzigen Adresse staut. Hier lauert eine Falle. Suchergebnisse und gefilterte Listen hängen oft an einer Sitzung auf dem Server; ändert sich die IP mitten in der Liste, kann die Website Sie auf die erste Seite zurückschicken oder dieselben Datensätze erneut liefern. Um eine Liste von Anfang bis Ende mit derselben Ausgangsadresse zu crawlen, öffnen Sie eine Sitzung mit Sticky-Proxy und wechseln die Identität, wenn die Liste zu Ende ist. Den Mechanismus beschreibt unser Beitrag Was ist IP-Rotation und wie funktioniert sie?. Wir haben die Zeile PROXY außerdem ausgefüllt und das Skript über einen lokalen Test-Proxy mit Authentifizierung laufen lassen; der gesamte Verkehr einschließlich robots.txt lief über den Proxy, das Ergebnis blieb gleich.
Einsatzbereiche
- Kategorie- und Katalogcrawl: Die Produktadressen aus den Kategorien eines Wettbewerbers zu sammeln ist der erste Schritt der Preisbeobachtung; den ganzen Ablauf beschreibt unser Beitrag Wettbewerberpreise im E-Commerce überwachen, die Infrastrukturseite finden Sie unter Preisüberwachung.
- Marktplatzlisten: Verkäufer- und Produktlisten reichen über Tausende Seiten, eine fortsetzbare Warteschlange ist hier Pflicht. Für Standort- und Sitzungseinstellungen sehen Sie sich unsere Seite E-Commerce-Proxy an.
- Massendaten aus offiziellen APIs: Cursor und
Link-Header tauchen hier am häufigsten auf. Wie die Sitzung bei APIs mit Anmeldung weitergereicht wird, steht in unserem Beitrag Sitzungen und Cookies in Python. - Kleine einmalige Aufgaben: Richten Sie für eine Tabelle mit fünfzig Zeilen keine Warteschlange ein; die Optionen ohne Code stehen in unserem Beitrag Daten von einer Website extrahieren.
Häufige Fehler
- Die letzte Seitennummer fest in den Code schreiben. Der Katalog wächst, aus 50 Seiten werden 53, und die letzten drei Seiten fehlen stillschweigend. Schreiben Sie nicht die Zahl, sondern die Abbruchbedingung.
- Keine Obergrenze setzen. Ein „Weiter"-Element, das auf sich selbst verweist, oder ein sich wiederholender Cursor lässt Ihr Skript stundenlang auf derselben Seite kreisen.
- Einen relativen Link von Hand zusammensetzen. Auf der Übungsseite lautet der „Weiter"-Link auf der ersten Seite
catalogue/page-2.html, auf der zweitenpage-3.html. Eine durch Zeichenkettenaddition gebaute Adresse geht auf der zweiten Seite kaputt;urljoinlöst beide richtig auf. - Den Stand getrennt von den Datensätzen schreiben. Code, der zuerst „Seite fertig" schreibt und danach die Datensätze einfügt, verliert diese Seite endgültig, wenn er dazwischen stirbt. Die Reihenfolge umzudrehen führt zu Dubletten. Beides gehört in dieselbe Transaktion.
- Dubletten mit einer Menge im Speicher aussortieren. Startet der Prozess neu, ist die Menge leer; Eindeutigkeit ist Aufgabe der Datenbank.
- Versteckten Links folgen. Code, der auf der Suche nach dem Paginierungslink alle
<a>-Elemente der Seite in die Warteschlange wirft, läuft auch in Fallenlinks, die für Menschen unsichtbar sind. Wählen Sie nur das Paginierungselement aus; die Einzelheiten stehen in unserem Beitrag Honeypot-Fallen.
Entscheidungshilfe
| Situation | Empfehlung |
|---|---|
| Auf der Seite gibt es einen „Weiter"-Link | Dem Link folgen, urljoin verwenden |
| Nur Seitennummern, letzte Seite unbekannt | Zahl erhöhen; leere Seite, 404 und „keine neuen Datensätze" zusammen nutzen |
| Im Netzwerk-Tab ist eine JSON-Anfrage zu sehen | Browser weglassen, die Anfrage direkt aufrufen |
| Die API liefert einen Cursor | Cursor unverändert weiterreichen, gesehene Cursor in einer Menge halten |
Die API liefert einen Link-Header | Der Adresse response.links["next"] folgen |
| Die Anfrage ist nicht wiederholbar, Endlosscrollen | Mit Playwright oder Selenium scrollen, anhalten, wenn die Kartenzahl nicht mehr wächst |
| Die Liste ist länger als 50 Seiten oder der Crawl dauert Minuten | SQLite-Warteschlange aufsetzen, Datensatz und Status in derselben Transaktion schreiben |
| Die Liste ändert sich während des Crawls | Stabiler Schlüssel, INSERT OR IGNORE, feste Sortierung, notfalls ein zweiter Durchgang |
| Gefilterte oder sitzungsgebundene Liste, mit Proxy | Sticky-Sitzung über die ganze Liste, neue Identität am Listenende |
Häufige Fragen
Was ist Cursor-Paginierung und worin unterscheidet sie sich von offset?
offset sagt „überspringe so viele Datensätze vom Anfang"; der Cursor sagt „gib mir die nach diesem Datensatz". Bei offset können Sie auf jede gewünschte Seite springen, aber wenn sich die Liste ändert, verschieben sich die Datensätze, und es entstehen Dubletten oder Lücken. Beim Cursor gibt es kein Springen, Sie gehen nur der Reihe nach vor; dafür bleibt Ihr Stand fest, auch wenn sich die Liste ändert. Der praktische Unterschied: Einen Crawl mit offset können Sie ab Seite 40 fortsetzen, bei einem abgelaufenen Cursor müssen Sie unter Umständen von vorn beginnen.
Was ist Endlosscrollen?
Es bedeutet, dass JavaScript beim Annähern an das Seitenende im Hintergrund eine neue Anfrage schickt und die eintreffenden Datensätze unten an die Liste hängt. Die Nutzerin sieht keine Seitennummern, aber die API dahinter arbeitet fast immer mit Seitennummern, offset oder Cursor. Beim Scraping ist diese API-Anfrage das Ziel.
Lässt sich die letzte Seite vorher herausfinden?
Manchmal. Die Zeile „Seite 1 von 50" unten auf der Seite, das Feld total_pages in der API-Antwort oder die Adresse rel="last" im Link-Header sagen es Ihnen. Nutzen Sie diese Angabe, um den Fortschritt anzuzeigen und das Ergebnis zu prüfen. Beenden Sie die Schleife trotzdem über die Abbruchbedingungen, denn die Gesamtzahl kann sich während des Crawls ändern.
Lassen sich Seiten parallel crawlen?
Bei den Arten Seitennummer und offset ja, weil Sie die Adressen vorab erzeugen können. Bei „Weiter"-Link und Cursor hängt jede Seite an der vorherigen, die Kette läuft der Reihe nach; Parallelität lässt sich nur zwischen verschiedenen Listen (Kategorien) herstellen. Parallelität hebt die Ratenbegrenzung nicht auf: Begrenzen Sie die Gesamtrate der Anfragen an dieselbe Website weiterhin.
Warum SQLite und nicht eine CSV- oder JSON-Datei?
An eine Datei anzuhängen ist einfach, gibt Ihnen aber drei Dinge nicht: die Eindeutigkeitsprüfung, das Schreiben nach dem Prinzip „ganz oder gar nicht" und eine abfragbare Warteschlange. SQLite liefert alle drei in einer einzigen Datei, ohne Installation. Nach dem Crawl ist es eine Sache weniger Zeilen, die Tabelle items nach CSV auszugeben.
Was passiert, wenn die Website während des Crawls die Anzahl der Datensätze pro Seite ändert?
Bei „Weiter"-Link und Cursor passiert nichts, weil die Website die nächste Seite nennt. Bei Seitennummer und offset verschieben sich die Grenzen, und manche Datensätze kommen zweimal, manche gar nicht. Die Dublettenprüfung über den Schlüssel löst das erste Problem; für das zweite brauchen Sie einen Abgleich mit der Gesamtzahl und notfalls einen zweiten Durchgang.
Fazit
Beim Crawlen mit Paginierung beantwortet der Code drei Fragen: Wo ist die nächste Seite, wann war die Liste zu Ende, wo ist mein Stand festgehalten. Bei der ersten folgen Sie dem Signal der Website („Weiter"-Link, Cursor, Link-Header), statt Adressen zu erfinden; beim Endlosscrollen suchen Sie zuerst die API-Anfrage dahinter. Bei der zweiten verlassen Sie sich nicht auf eine einzige Bedingung, sondern ergänzen immer „keine neuen Datensätze" und eine Seitenobergrenze. Bei der dritten schreiben Sie Datensätze und Warteschlangenstatus in derselben SQLite-Transaktion; wo der Prozess auch stirbt, der Crawl läuft auf der Seite weiter, auf der er stand. Halten Sie die Anfragerate niedrig und befolgen Sie die Regeln der robots.txt. Möglichkeiten für Standort und Lastverteilung finden Sie bei unseren Proxy-Diensten.




