Abgeschnittene HTML-Antworten: So Erkennst Du eine Gescrapte Seite, die Unvollständig Ankam
Dein Scraper lief die ganze Nacht. Jede Anfrage gab HTTP 200 zurück. Die HTML-Dateien sehen gut aus, wenn du sie öffnest: Sie beginnen mit <html> und enden mit </html>. Aber einige Seiten enthalten 12 Produkte statt 48, und bei manchen wirft das eingebettete JSON einen Parse-Fehler. Du rufst dieselbe URL von Hand ab und bekommst die vollständige Seite.
Das ist eine abgeschnittene Antwort. Der Server, oder etwas zwischen dir und dem Server, hat mitten in der Seite aufgehört zu senden, und nichts in deinem Stack hat sich beschwert. Das passiert seltener als eine Blockade, ist aber schwerer zu erkennen, weil es fast genau wie ein Erfolg aussieht.
Dieser Leitfaden erklärt, wie eine 200-Antwort abgeschnitten ankommen kann, welche Fälle dein HTTP-Client selbst bemerkt und wie du jede Seite auf Vollständigkeit prüfst, bevor du sie parst.
Wie eine Abgeschnittene Antwort Aussieht
Abgeschnittene Antworten hält man leicht für einen Parser-Bug oder eine Änderung an der Website. Die typischen Anzeichen:
- Zufällige Lücken. Dieselbe Seitenvorlage liefert meistens vollständige Ergebnisse und ab und zu weniger Einträge.
- Eingebettetes JSON, das sich nicht parsen lässt.
json.loads()scheitert mit “Unterminated string” oder “Expecting ’,’ delimiter” an Daten, die gestern noch problemlos geparst wurden. - Was unten steht, fehlt zuerst. Footer-Links, Paginierung, “ähnliche Produkte” und alles andere am Ende des Dokuments verschwindet, während Header und die ersten Einträge in Ordnung sind.
- Ein neuer Versuch behebt es. Dieselbe URL liefert eine Minute später die vollständige Seite.
- Die Antwortgrößen schwanken stark. Die meisten Antworten eines Seitentyps liegen um eine bestimmte Größe, mit einem verstreuten Rest kleinerer Antworten.
Wenn diese Anzeichen nur bei einigen Anfragen auftreten und immer das Ende des Dokuments fehlt, solltest du an eine abgeschnittene Antwort denken, bevor du deine Selektoren umschreibst.
Warum ein 200 Abgeschnitten Ankommen Kann
Der Statuscode ist das Erste, was ein Server sendet. Wenn der Body zu fließen beginnt, ist die 200 schon unterwegs und lässt sich nicht mehr zurücknehmen. Alles, was danach schiefgeht, muss auf andere Weise signalisiert werden.
HTTP/1.1 kennt drei Wege, das Ende eines Antwort-Bodys zu markieren:
- Content-Length. Der Server sagt vorab, wie viele Bytes kommen.
- Chunked Transfer Encoding. Der Body kommt in Stücken, und ein letzter Chunk der Länge null bedeutet “fertig”.
- Verbindungsabbau. Gar keine Längenangabe. Der Body endet, wenn die Verbindung geschlossen wird.
HTTP/2 und HTTP/3 haben ihr eigenes Signal für das Ende eines Streams, aber das Prinzip ist dasselbe. Eine Antwort wird abgeschnitten, wenn der Body zu früh endet und das Framing das entweder als Fehler meldet oder, schlimmer noch, als normales Ende.
Häufige Ursachen:
- Serverseitiges Rendering per Streaming. Moderne Frameworks senden das HTML, während es gerendert wird. Header und die 200 gehen zuerst raus, der Rest folgt. Wenn das Rendering mittendrin fehlschlägt oder in einen Timeout läuft, kann der Server nur noch den Stream beenden. Auf eine 500 umschalten kann er nicht mehr.
- Proxys, Load Balancer und Gateways. Alles dazwischen kann in einen Timeout oder ein Größenlimit laufen und den Stream schließen. Manche tun das mit einem sauberen letzten Chunk, sodass der Client eine normale, nur kürzere Antwort sieht.
- Kompression. Ein gzip-Body, der mittendrin aufhört, kann ohne jeden Fehler zu einem kürzeren Dokument dekomprimiert werden.
- Dein eigener Code. Ein Größenlimit beim Lesen eines Streams, ein Retry-Wrapper, der den letzten unvollständigen Body zurückgibt, oder ein Headless-Browser, der das HTML holt, bevor die Seite fertig ist.
Welche Abbrüche Dein Client Bemerkt
Manche Abbrüche lösen einen Fehler aus, andere nicht. Das passiert, wenn ein Server 200 sendet und dann zu früh aufhört, getestet mit Python requests auf urllib3 2.x, dem eingebauten fetch von Node und curl:
| Was der Server getan hat | Python requests | Node fetch | curl |
|---|---|---|---|
| Weniger Bytes gesendet als in Content-Length | ChunkedEncodingError (IncompleteRead) | TypeError: terminated | Fehler 18, “transfer closed with N bytes remaining” |
| Chunked-Stream ohne letzten Chunk abgebrochen | ChunkedEncodingError (“Response ended prematurely”) | TypeError: terminated | Fehler 18, “transfer closed with outstanding read data” |
| Chunked-Stream nach kurzem Body sauber beendet | kein Fehler, Status 200 | kein Fehler | kein Fehler |
| Keine Länge gesendet und Verbindung geschlossen | kein Fehler, Status 200 | kein Fehler | kein Fehler |
| gzip-Body halbiert, Framing sauber | kein Fehler, kürzerer Text | kein Fehler | kein Fehler |
Zwei Erkenntnisse. Erstens helfen dir die lauten Fälle nur, wenn du sie laut sein lässt. Ein pauschales except Exception: pass oder ein Retry-Helfer, der zurückgibt, was er zuletzt bekommen hat, macht aus einem klaren Fehler stillschweigend unvollständige Daten. Zweitens sehen die unteren drei Zeilen auf HTTP-Ebene genau wie ein Erfolg aus, also brauchst du Prüfungen am Inhalt selbst.
Noch ein Detail: urllib3 2.x prüft Content-Length standardmäßig. Die ältere 1.x-Reihe tat das nicht, auf einem alten Stack kann also sogar die erste Zeile unbemerkt durchgehen. Führe pip show urllib3 aus, wenn du dir nicht sicher bist.
Dein Parser Verdeckt den Schaden
HTML-Parser sind darauf ausgelegt, nachsichtig zu sein. Browser müssen kaputtes Markup darstellen, deshalb schließen Parser wie lxml und Pythons html.parser alle offenen Elemente, wenn die Eingabe endet. Für die Darstellung ist das großartig. Für einen Scraper ist es ein Problem.
Gib ein abgeschnittenes Dokument an BeautifulSoup und gib es aus:
from bs4 import BeautifulSoup
cut = '<html><head><script id="__NEXT_DATA__" type="application/json">{"props":{"items":[{"id":1,"title":"A'
print(BeautifulSoup(cut, "lxml"))
# <html><head><script id="__NEXT_DATA__" type="application/json">{"props":{"items":[{"id":1,"title":"A</script></head></html>
Die Ausgabe endet mit </script></head></html>, aber die Eingabe hatte diese Tags nie. Führe soup.select("div.product-card") aus und du bekommst weniger Einträge, ohne Exception. Jeder Parser repariert auf seine Weise (html.parser verwirft den Inhalt des unvollständigen Scripts komplett, lxml behält ihn), aber keiner sagt dir, dass etwas gefehlt hat.
Daraus folgen zwei Regeln:
- Prüfe die Vollständigkeit am Rohtext, vor dem Parsen. Nach dem Parsen sind die Spuren verschwunden.
- Speichere die Rohantwort, keine neu serialisierte Kopie.
str(soup)odersoup.prettify()ergänzen die fehlenden schließenden Tags, sodass dein Archiv vollständig aussieht, obwohl es das nicht war. Neu serialisieren kann auch den Inhalt verändern: Läuft eine JSON-Antwort durch einen HTML-Parser, kommen Zeichen wie&als&wieder heraus.
So Erkennst Du eine Abgeschnittene Seite
Keine einzelne Prüfung fängt alles ab. Kombiniere ein paar günstige.
1. Lass den Transport Laut Scheitern
Fange die konkreten Exceptions ab und behandle sie als “unvollständig, erneut versuchen”, nie als “fertig”:
from requests.exceptions import ChunkedEncodingError, ConnectionError
try:
resp = session.get(url, timeout=(5, 30))
except (ChunkedEncodingError, ConnectionError) as e:
# the body was cut off in transit - retry, do not parse
...
In Node erscheint der Fehler nicht, wenn fetch() auflöst (da hast du die 200 schon). Er erscheint, wenn du await res.text() aufrufst, also gehört dieser Aufruf ebenfalls in deinen try-Block.
2. Prüfe, ob das Dokument Dort Endet, Wo Es Soll
Suche im Rohtext nach </html> in der Nähe des Endes:
def has_closing_html(raw: str) -> bool:
return "</html>" in raw[-2048:].lower()
Schau dir die letzten paar KB an statt nur die allerletzten Bytes, denn viele Seiten setzen Scripts oder Kommentare hinter das schließende Tag. Und lerne jede Website erst kennen: Die HTML-Spezifikation erlaubt es, </body> und </html> wegzulassen. Verlass dich auf diese Prüfung also nur bei Websites, deren gesunde Seiten das Tag tatsächlich enthalten.
3. Validiere Eingebettetes JSON
Das ist deine stärkste Prüfung. JSON kennt keine Fehlerkorrektur: Ein abgeschnittenes Objekt lässt sich niemals parsen. Framework-Zustand wie __NEXT_DATA__, application/ld+json-Blöcke und eingebetteter App-Zustand sind gute Frühwarnzeichen:
import json
import re
NEXT_DATA = re.compile(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)
def next_data_ok(raw: str) -> bool:
if '<script id="__NEXT_DATA__"' not in raw:
return True # this page does not use it
m = NEXT_DATA.search(raw)
if not m:
return False # opening tag present, closing tag never arrived
try:
json.loads(m.group(1))
return True
except json.JSONDecodeError:
return False
Wenn du schon Daten aus diesen Blöcken ziehst (siehe wie du das versteckte JSON hinter einer JavaScript-Seite findest), ist ein Parse-Fehler dein Signal für einen neuen Versuch, kein Grund, die Seite zu überspringen.
4. Suche eine Markierung für das Seitenende
Wähle etwas, das nur am unteren Ende einer gesunden Seite vorkommt: den Footer, den Paginierungsblock, ein bekanntes letztes Script. Fehlt es, ist die Seite nicht vollständig angekommen. Kombiniere das bei Listenseiten mit einer Zählung der Einträge. Unser Leitfaden zum Erkennen von Soft Blocks behandelt zählbasierte Prüfungen ausführlicher, da unvollständige Seiten dort auch als Anti-Bot-Taktik auftauchen.
5. Beobachte die Antwortgrößen
Protokolliere die Bytelänge jeder Antwort, gruppiert nach Seitentyp. Abgeschnittene Antworten zeigen sich als Gruppe kleiner Ausreißer. Für eine einzelne Seite ist das kein Beweis, aber ein plötzlicher Anstieg kurzer Antworten zeigt dir, dass sich vorgelagert etwas geändert hat.
Alles Zusammen
Ein kleiner Wächter, der vor deinem Parser läuft, mit einer begrenzten Zahl neuer Versuche:
import requests
def completeness_problems(raw, end_marker=None):
problems = []
if not has_closing_html(raw):
problems.append("no </html> near the end")
if end_marker and end_marker not in raw:
problems.append(f"end marker {end_marker!r} missing")
if not next_data_ok(raw):
problems.append("__NEXT_DATA__ cut off or invalid")
return problems
def fetch_complete(url, end_marker=None, attempts=3):
last = []
for _ in range(attempts):
try:
with requests.Session() as s: # fresh connection per attempt
resp = s.get(url, timeout=(5, 30))
except (ChunkedEncodingError, ConnectionError) as e:
last = [f"transfer broken: {e}"]
continue
problems = completeness_problems(resp.text, end_marker)
if not problems:
return resp.text
last = problems
raise RuntimeError(f"incomplete after {attempts} attempts: {last}")
Abgeschnittene Antworten sind meist vorübergehend, deshalb behebt ein neuer Versuch über eine frische Verbindung die meisten Fälle. Wenn du über Proxys gehst, wechsle beim neuen Versuch auch den Proxy. Halte die Zahl der Versuche klein und protokolliere jeden Fehlschlag mit seinem Grund. Steigt die Abbruchrate bei einer Website, drossle das Tempo: Lange, schwere Seiten werden abgeschnitten, wenn ein Server unter Last steht.
Headless-Browser: Nicht zu Früh Abgreifen
Bei Playwright oder Selenium bringt die Prüfung auf das schließende Tag nichts. page.content() serialisiert den aktuellen DOM, und ein DOM ist immer wohlgeformt. Das Risiko liegt hier darin, das HTML zu holen, bevor die Daten angekommen sind, besonders bei Streaming-Seiten. Next.js-Seiten mit dem App Router liefern ihre Daten zum Beispiel in self.__next_f.push(...)-Script-Chunks, während der Stream eintrifft.
Warte auf den Inhalt, den du brauchst, nicht nur auf ein Load-Event:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(url, wait_until="load")
page.wait_for_selector("footer", state="attached", timeout=15000)
page.wait_for_selector("div.product-card", timeout=15000)
html = page.content()
browser.close()
Führe dann dieselben JSON- und Markierungsprüfungen auf html aus, bevor du ihm vertraust.
FAQ
Bedeutet ein Statuscode 200, dass die Seite vollständig ist?
Nein. Der Statuscode wird vor dem Body gesendet. Er sagt dir, dass der Server eine erfolgreiche Antwort begonnen hat, nicht dass er sie beendet hat. Die Vollständigkeit musst du am Body prüfen.
Warum zeigt BeautifulSoup schließende Tags, die nicht in der Antwort standen?
HTML-Parser schließen offene Elemente, wenn die Eingabe endet, genau wie Browser. Dadurch sieht ein abgeschnittenes Dokument nach dem Parsen wohlgeformt aus. Prüfe den Rohtext, bevor du ihn parst.
Soll ich eine abgeschnittene Antwort erneut anfragen?
Ja, mit Limit. Die meisten Abbrüche sind vorübergehend, und dieselbe URL liefert beim nächsten Versuch die vollständige Seite. Nutze eine frische Verbindung, begrenze die Versuche auf zwei oder drei und protokolliere den Grund, damit du ein Muster erkennst.
Was ist die zuverlässigste einzelne Prüfung?
Das Parsen von eingebettetem JSON wie __NEXT_DATA__ oder JSON-LD. Anders als HTML hat JSON keinen nachsichtigen Parser, ein abgeschnittener Block scheitert also immer beim Parsen.
Fazit
Abgeschnittene Antworten sind selten, und genau deshalb tun sie weh. Sie rutschen an Statusprüfungen und Parsern vorbei und tauchen Wochen später als Lücken in deinen Daten auf. Ein Wächter aus wenigen Zeilen, der den Roh-Body auf ein schließendes Tag, gültiges eingebettetes JSON und eine Markierung für das Seitenende prüft, fängt fast alle ab, und eine begrenzte Zahl neuer Versuche holt die Daten zurück.
Wenn du Browser und Proxys lieber nicht selbst betreiben willst, ruft ScrapeUnblocker die Seiten für dich ab, rendert sie und liefert das HTML in einem einzigen API-Aufruf. Die Dokumentation zeigt, wie die Anfrage aussieht. Behalte den Vollständigkeitswächter vor deinem Parser, egal woher deine Daten kommen: Er prüft das Eine, was ein Statuscode nicht prüfen kann.
ScrapeUnblocker kostenlos testen
Über 95 % Erfolgsquote · ab 0,55 € pro 1.000 Aufrufe (ggf. zzgl. MwSt.) · 500 kostenlose Anfragen bei der Registrierung.