Scraping

Zeichenkodierungen in gescrapten Daten: Zeichensätze erkennen und Mojibake beheben

Wir haben 193 Top-Startseiten in sechs Ländern abgerufen. Ein gängiger Python-Standard hat den Text von 37 davon verstümmelt. Wie man gescrapte Seiten so dekodiert wie Browser es tun.

Chris Collins

Chris Collins

1. Oktober 2026 · 9 Min. Lesezeit

Mojibake ist der verstümmelte Text, der entsteht, wenn Bytes mit dem falschen Zeichensatz dekodiert werden: „Café” statt „Café”, oder ein japanischer Titel, der zu einer Reihe wahlloser lateinischer Buchstaben wird. In einem Browser ist das selten, weil Browser einem präzisen, standardisierten Verfahren folgen, um die Kodierung einer Seite zu bestimmen. In Scraping-Pipelines ist es häufig, weil die HTTP-Bibliothek meist etwas Einfacheres macht.

Wir haben gemessen, wie oft das bei echten Websites eine Rolle spielt, und die Antwort war: häufiger, als die meisten Teams annehmen. Dieser Leitfaden behandelt, was wir herausgefunden haben, warum es passiert, und Dekodierungscode, der derselben Reihenfolge folgt wie Browser.

Kernaussagen

  • Am 1. Oktober 2026 haben wir die Startseiten der Top 50 Domains in sechs Länder-Domains abgerufen (Japan, Korea, China, Russland, Taiwan und Deutschland). 193 lieferten eine Seite zurück.
  • Neun der 193 waren nicht UTF-8: Shift_JIS in Japan, EUC-KR in Korea, windows-1251 in Russland und ISO-8859-1 in Deutschland.
  • Das größere Problem war nicht die Legacy-Kodierung, sondern eine Bibliotheksvorgabe. Pythons requests dekodierte 37 der 193 Seiten, etwa 19%, als ISO-8859-1 und verstümmelte dabei jedes Nicht-ASCII-Zeichen, weil der Server keinen charset in seinem Header angab.
  • Legacy-Bezeichnungen bedeuten nicht das, was ihre Namen sagen. Browser dekodieren „ISO-8859-1” als windows-1252 und „Shift_JIS” als die Windows-Variante, und strikte Dekoder machen bei manchen Zeichen Fehler.
  • Dekodieren Sie in der Reihenfolge des Browsers: Byte Order Mark, HTTP-Header, meta-Tag, dann Gültigkeit und Erkennung.

Was wir gemessen haben

Wir haben die Top 50 Domains mit den Endungen .jp, .kr, .cn, .ru, .tw und .de aus der Tranco-Liste populärer Domains genommen, jede Startseite einmal abgerufen und die 193 behalten, die eine HTML-Seite mit Status 200 zurückgaben. Für jede Seite haben wir festgehalten, wie sie ihre Kodierung deklarierte, was sie tatsächlich war, und was .text der requests-Bibliothek erzeugte.

Länder-DomainSeitenNicht UTF-8Verstümmelt durch requests’ .text
Japan (.jp)364 (Shift_JIS)8
Korea (.kr)312 (EUC-KR)4
China (.cn)33010
Russland (.ru)252 (windows-1251)4
Taiwan (.tw)2906
Deutschland (.de)391 (ISO-8859-1)5
Gesamt193937

UTF-8 hat sich eindeutig durchgesetzt: 184 von 193 Seiten verwendeten es. Aber die rechte Spalte zeigt, dass der Sieg im Kodierungskrieg das Problem nicht beendet hat. Die meisten verstümmelten Seiten waren UTF-8. Sie deklarierten es in einem <meta>-Tag statt im HTTP-Header, und die Bibliothek schaute dort nie nach.

Warum die Bibliothek es falsch machte

Wenn eine Antwort Content-Type: text/html ohne charset sagt, setzt requests die Kodierung auf ISO-8859-1, gemäß der alten HTTP/1.1-Vorgabe für Textarten. Sie liest nicht den <meta charset>-Tag der Seite. Jedes Byte über 127 wird dann als Latin-1-Zeichen dekodiert, sodass UTF-8-Text in jeder Sprache verstümmelt herauskommt:

OriginalAls Latin-1 dekodierte Bytes
CaféCafé
価格 (Japanisch, „Preis”)ä¾¡æ ¼
JRA日本中央競馬会 (eine Shift_JIS-Seite)JRA gefolgt von Steuerzeichen und wahllosen lateinischen Buchstaben

Dabei wird kein Fehler ausgelöst. Der Text bleibt eine gültige Zeichenkette, lässt sich weiterhin als HTML parsen, und Selektoren greifen weiterhin. Der Schaden zeigt sich erst später, als nicht durchsuchbare Produktnamen, defekte Deduplizierung und Datenmüll in den Trainingsdaten eines Modells, was einen klassischen Fall von einer erfolgreichen Anfrage, die falschen Inhalt zurückgibt darstellt.

Legacy-Bezeichnungen bedeuten nicht das, was sie sagen

Die zweite Falle ist subtiler. Wenn eine Seite tatsächlich eine Legacy-Kodierung deklariert, verwenden Browser nicht den strikten Standard dieses Namens. Der WHATWG Encoding Standard, den Browser implementieren, bildet mehrere Bezeichnungen auf Obermengen ab:

Deklarierte BezeichnungWas Browser tatsächlich dekodieren
iso-8859-1, latin1, asciiwindows-1252
gb2312, gbkgb18030
shift_jisShift_JIS mit den Windows-Erweiterungen (Codepage 932)
euc-krEUC-KR mit den Windows-Erweiterungen (Codepage 949)
big5Big5 mit den ergänzenden Hongkong-Zeichen

Das Dekodieren mit dem strikten Codec desselben Namens erzeugt Fehler oder, schlimmer, andere Zeichen. Auf einer japanischen Unterhaltungsseite, die Shift_JIS deklarierte, verwandelte Pythons strikter shift_jis-Codec jede Vollbreiten-Tilde „~” (U+FF5E) in einen Wellenstrich „〜” (U+301C), so oft, wie die Seite sie verwendete. Der Index des Encoding Standard bildet dieses Byte-Paar auf die Vollbreiten-Tilde ab, was Besucher auch sehen. Die beiden sehen fast identisch aus und werden beim Vergleich als unterschiedliche Zeichenketten behandelt, sodass eine Preisspanne wie „1.000~2.000” lautlos aufhört zu matchen.

Die anderen Zuordnungen schlagen lauter fehl. Eine mit gb2312 gekennzeichnete Seite, die ein Zeichen außerhalb dieses Standards verwendet, oder eine euc-kr-Seite mit einer der erweiterten Hangul-Silben, löst unter Pythons strikten Codecs einen Dekodierungsfehler aus. Und eine mit ISO-8859-1 gekennzeichnete Seite, die das Eurozeichen verwendet, bekommt stattdessen ein unsichtbares Steuerzeichen, weil das Euro-Zeichen nur in windows-1252 existiert.

So dekodieren Sie wie Browser

Der HTML Standard definiert die Reihenfolge: eine Byte Order Mark gewinnt, dann die von der Transportschicht benannte Kodierung (der HTTP-Header), dann eine <meta>-Deklaration, die durch Scannen des Dokumentanfangs gefunden wird, wobei Autoren diese innerhalb der ersten 1024 Bytes platzieren müssen. Folgt man derselben Reihenfolge, mit den Bezeichnungs-Zuordnungen des Browsers und einem Erkennungs-Fallback, ergibt sich dies:

import codecs
import re

from charset_normalizer import from_bytes

# Legacy labels mean what browsers decode them as (WHATWG Encoding Standard), not their strict namesakes.
BROWSER_DECODER = {
    "iso-8859-1": "cp1252", "latin1": "cp1252", "ascii": "cp1252", "us-ascii": "cp1252",
    "gb2312": "gb18030", "gbk": "gb18030", "x-gbk": "gb18030",
    "shift_jis": "cp932", "sjis": "cp932", "x-sjis": "cp932", "windows-31j": "cp932",
    "euc-kr": "cp949", "ks_c_5601-1987": "cp949",
    "big5": "big5hkscs",
}
HEADER_CHARSET = re.compile(r"charset\s*=\s*[\"']?([^;\"'\s]+)", re.I)
META_CHARSET = re.compile(rb"""<meta[^>]+charset\s*=\s*["']?\s*([A-Za-z0-9_.:-]+)""", re.I)


def python_codec(label):
    """Map a declared charset label to a Python codec name, or None if it is unknown."""
    label = label.strip().lower()
    label = BROWSER_DECODER.get(label, label)
    try:
        return codecs.lookup(label).name
    except LookupError:
        return None


def decode_html(body, content_type=""):
    """Decode HTML bytes in the browser's order: BOM, HTTP header, <meta>, then UTF-8, then detection."""
    if body.startswith(codecs.BOM_UTF8):
        return body[3:].decode("utf-8", "replace"), "utf-8 (BOM)"
    header = HEADER_CHARSET.search(content_type or "")
    meta = META_CHARSET.search(body[:1024])  # the HTML spec requires the declaration there
    labels = (("header", header and header.group(1)), ("meta", meta and meta.group(1).decode("ascii", "replace")))
    for source, label in labels:
        codec = label and python_codec(label)
        if codec:
            return body.decode(codec, "replace"), f"{codec} ({source})"
    try:
        return body.decode("utf-8"), "utf-8 (valid)"
    except UnicodeDecodeError:
        guess = from_bytes(body).best()
        codec = guess.encoding if guess else "cp1252"
        return body.decode(codec, "replace"), f"{codec} (detected)"

Übergeben Sie ihm die rohen Bytes (response.content bei requests) und den Content-Type-Header, niemals response.text. Die Funktion gibt den Text und einen Hinweis zurück, wie die Kodierung gewählt wurde, was es wert ist, zusammen mit jeder Seite gespeichert zu werden, damit eine schlechte Entscheidung später nachverfolgt werden kann.

Über alle 193 Seiten ausgeführt, dekodierte sie 192 ohne ein einziges Ersatzzeichen. Die verbleibende Seite deklarierte UTF-8 und enthielt zwei ungültige Byte-Sequenzen, die ein Browser ebenfalls als Ersatzzeichen anzeigt. Sie erzeugte außerdem den korrekten Text für die 37 Seiten, die die Bibliotheksvorgabe verstümmelt hatte, sowie für die 9 Seiten in Legacy-Kodierungen. Getestet an konstruierten Grenzfällen, dekodiert sie eine mit gb2312 gekennzeichnete Seite mit einem nur in GBK vorhandenen Zeichen, eine euc-kr-Seite mit einer erweiterten Hangul-Silbe, eine ISO-8859-1-Seite mit einem Eurozeichen und eine nicht deklarierte Shift_JIS-Seite, alle ohne Fehler.

Was sich sonst noch zeigte

  • Deklarationen an der falschen Stelle. In unserem ersten Durchlauf platzierten 17 Seiten ihr <meta charset> nach den ersten 1024 Bytes, entgegen der HTML-Spezifikation. Dreizehn nannten den charset auch im Header, und die anderen vier waren gültiges UTF-8, sodass nichts kaputtging, aber ein Dekoder, der sich allein auf das meta-Tag verließe, hätte es verpasst.
  • Widersprüchliche Deklarationen. Eine taiwanische Website sendete UTF-8 im Header und Big5 in ihrem meta-Tag. Der Header gewinnt, wie bei Browsern auch, und in diesem Fall war das richtig.
  • Byte Order Marks. Vier Seiten begannen mit einer UTF-8-Byte-Order-Mark. Manche ohne charset im Header wurden dennoch von der Bibliothek als Latin-1 dekodiert, und selbst wo der Header korrekt war, ließ die Bibliothek das unsichtbare Zeichen am Textanfang stehen.

Praktische Regeln

  • Dekodieren Sie Bytes selbst. Bewahren Sie die rohe Antwort auf, und wenn Sie rohe Antworten archivieren, können Sie sie später neu dekodieren, falls sich Ihre Regeln ändern.
  • Nehmen Sie niemals Latin-1 an. Behandeln Sie einen fehlenden charset im Header als „weiter suchen”, nicht als ISO-8859-1.
  • Speichern Sie alles als UTF-8. Dekodieren Sie einmal am Rand der Pipeline, notieren Sie, welche Kodierung verwendet wurde, und behalten Sie ab dann UTF-8 bei.
  • Achten Sie auf Ersatzzeichen. Ein plötzlicher Anstieg von U+FFFD in einem Feld ist ein billiger, zuverlässiger Alarm und gehört neben die in Schema-Drift in gescrapten Daten beschriebenen Prüfungen.
  • Normalisieren Sie nach dem Dekodieren. Korrekte Zeichen lassen sich dennoch auf mehr als eine Art schreiben, etwa Vollbreiten-Ziffern oder unterschiedliche Tilden. Normalisieren Sie vor dem Vergleichen oder Deduplizieren, wie in Normalisierung von Preisen, Zahlen und Daten über Gebietsschemata hinweg beschrieben.

Das Fazit

Fast das gesamte Web ist inzwischen UTF-8, und Pipelines verstümmeln es trotzdem, weil der häufigste Fehler nicht eine exotische Kodierung ist, sondern eine Bibliotheksvorgabe, die die eigene Deklaration der Seite ignoriert. In unserer Stichprobe verstümmelte diese Vorgabe etwa jede fünfte Seite, und keine davon löste einen Fehler aus.

Dekodieren Sie ausgehend von Bytes, in der Reihenfolge, die Browser verwenden, mit den Bezeichnungen so zugeordnet, wie Browser sie zuordnen, und notieren Sie, welche Regel entschieden hat. Das macht aus einem unsichtbaren Datenqualitätsproblem ein gelöstes.

Quellen und Referenzen

Bereit, loszulegen?

Testen Sie Shifters Residential-Proxys, 205M+ IPs, 195+ Länder, ab 0,75 $/GB.

Jetzt starten