Scraping

Encodages de caractères dans les données extraites : détecter les jeux de caractères et corriger le mojibake

Nous avons récupéré 193 pages d'accueil parmi les plus visitées dans six pays. Un défaut courant de Python a corrompu le texte de 37 d'entre elles. Comment décoder les pages extraites comme le font les navigateurs.

Chris Collins

Chris Collins

1 octobre 2026 · 10 min de lecture

Mojibake désigne le texte corrompu obtenu quand des octets sont décodés avec le mauvais jeu de caractères : « Café » au lieu de « Café », ou un titre japonais transformé en une suite de lettres latines aberrantes. Dans un navigateur, c’est rare, car les navigateurs suivent une procédure précise et standardisée pour déterminer l’encodage d’une page. Dans les pipelines de scraping, c’est courant, car la bibliothèque HTTP fait généralement quelque chose de plus simple.

Nous avons mesuré la fréquence de ce problème sur des sites réels, et la réponse est : plus souvent que ce que la plupart des équipes supposent. Ce guide couvre ce que nous avons trouvé, pourquoi cela se produit, et un code de décodage qui suit le même ordre que les navigateurs.

Points clés

  • Le 1 octobre 2026, nous avons récupéré les pages d’accueil des 50 principaux domaines de six domaines nationaux (Japon, Corée, Chine, Russie, Taïwan et Allemagne). 193 ont renvoyé une page.
  • Neuf des 193 n’étaient pas en UTF-8 : Shift_JIS au Japon, EUC-KR en Corée, windows-1251 en Russie et ISO-8859-1 en Allemagne.
  • Le problème le plus important n’était pas les encodages hérités mais un comportement par défaut de bibliothèque. La bibliothèque requests de Python a décodé 37 des 193 pages, soit environ 19 %, en ISO-8859-1, corrompant chaque caractère non-ASCII, parce que le serveur ne précisait pas de charset dans son en-tête.
  • Les étiquettes héritées ne signifient pas ce que leur nom indique. Les navigateurs décodent « ISO-8859-1 » comme windows-1252 et « Shift_JIS » comme la variante Windows, et les décodeurs stricts se trompent sur certains caractères.
  • Décodez dans l’ordre du navigateur : marque d’ordre des octets, en-tête HTTP, balise meta, puis validité et détection.

Ce que nous avons mesuré

Nous avons pris les 50 principaux domaines se terminant en .jp, .kr, .cn, .ru, .tw et .de à partir de la liste Tranco des domaines populaires, récupéré chaque page d’accueil une fois, et conservé les 193 qui ont renvoyé une page HTML avec le statut 200. Pour chaque page, nous avons enregistré comment elle déclarait son encodage, quel était l’encodage réel, et ce que produisait .text de la bibliothèque requests.

Domaine nationalPagesPas en UTF-8Corrompu par .text de requests
Japon (.jp)364 (Shift_JIS)8
Corée (.kr)312 (EUC-KR)4
Chine (.cn)33010
Russie (.ru)252 (windows-1251)4
Taïwan (.tw)2906
Allemagne (.de)391 (ISO-8859-1)5
Total193937

L’UTF-8 a clairement gagné : 184 pages sur 193 l’utilisaient. Mais la colonne de droite montre que gagner la guerre des encodages n’a pas mis fin au problème. La plupart des pages corrompues étaient en UTF-8. Elles le déclaraient dans une balise <meta> plutôt que dans l’en-tête HTTP, et la bibliothèque n’y a jamais regardé.

Pourquoi la bibliothèque s’est trompée

Quand une réponse indique Content-Type: text/html sans charset, requests définit l’encodage sur ISO-8859-1, suivant l’ancien comportement par défaut de HTTP/1.1 pour les types texte. Elle ne lit pas la balise <meta charset> de la page. Chaque octet au-dessus de 127 est alors décodé comme un caractère Latin-1, donc le texte UTF-8 dans n’importe quelle langue ressort corrompu :

OriginalOctets décodés en Latin-1
CaféCafé
価格 (japonais, « prix »)ä¾¡æ ¼
JRA日本中央競馬会 (une page Shift_JIS)JRA suivi de caractères de contrôle et de lettres latines aberrantes

Rien ne génère d’erreur. Le texte reste une chaîne valide, il continue à être analysé comme du HTML, et les sélecteurs continuent à correspondre. Les dégâts apparaissent plus tard, sous forme de noms de produits introuvables, de déduplication cassée et de données corrompues dans l’entraînement d’un modèle, ce qui en fait un cas classique où une requête réussie renvoie le mauvais contenu.

Les étiquettes héritées ne signifient pas ce qu’elles disent

Le second piège est plus subtil. Quand une page déclare bien un encodage hérité, les navigateurs n’utilisent pas la norme stricte de ce nom. Le WHATWG Encoding Standard, que les navigateurs implémentent, associe plusieurs étiquettes à des sur-ensembles :

Étiquette déclaréeCe que les navigateurs décodent réellement
iso-8859-1, latin1, asciiwindows-1252
gb2312, gbkgb18030
shift_jisShift_JIS avec les extensions Windows (page de code 932)
euc-krEUC-KR avec les extensions Windows (page de code 949)
big5Big5 avec les caractères supplémentaires de Hong Kong

Décoder avec le codec strict du même nom produit des erreurs ou, pire, des caractères différents. Sur un site japonais de divertissement déclarant Shift_JIS, le codec strict shift_jis de Python a transformé chaque tilde pleine chasse « ~ » (U+FF5E) en tiret ondulé « 〜 » (U+301C), autant de fois que la page utilisait ce caractère. L’index du Encoding Standard associe cette paire d’octets à la tilde pleine chasse, ce que voient les visiteurs. Les deux se ressemblent presque et se comparent comme des chaînes différentes, donc une fourchette de prix comme « 1 000~2 000 » cesse silencieusement de correspondre.

Les autres correspondances échouent plus bruyamment. Une page étiquetée gb2312 qui utilise un caractère en dehors de cette norme, ou une page euc-kr avec l’une des syllabes hangul étendues, génère une erreur de décodage avec les codecs stricts de Python. Et une page étiquetée ISO-8859-1 qui utilise le symbole euro obtient à la place un caractère de contrôle invisible, parce que l’euro n’existe que dans windows-1252.

Décoder comme les navigateurs

Le HTML Standard définit l’ordre : une marque d’ordre des octets l’emporte, puis l’encodage nommé par la couche transport (l’en-tête HTTP), puis une déclaration <meta> trouvée en scannant le début du document, que les auteurs sont tenus de placer dans les premiers 1024 octets. En suivant le même ordre, avec les correspondances d’étiquettes du navigateur et un repli sur la détection, on obtient ceci :

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)"

Transmettez-lui les octets bruts (response.content avec requests) et l’en-tête Content-Type, jamais response.text. Elle renvoie le texte et une indication de la façon dont l’encodage a été choisi, qu’il vaut la peine de stocker à côté de chaque page afin qu’une mauvaise décision puisse être retracée plus tard.

Exécutée sur les 193 pages, elle en a décodé 192 sans le moindre caractère de remplacement. La page restante déclarait UTF-8 et contenait deux séquences d’octets invalides, qu’un navigateur affiche également sous forme de caractères de remplacement. Elle a également produit le texte correct pour les 37 pages que le comportement par défaut de la bibliothèque avait corrompues, et pour les 9 pages dans des encodages hérités. Testée sur des cas limites construits, elle décode sans erreur une page étiquetée gb2312 contenant un caractère exclusif à GBK, une page euc-kr avec une syllabe hangul étendue, une page ISO-8859-1 avec un symbole euro et une page Shift_JIS non déclarée.

Autres observations

  • Déclarations mal placées. Lors de notre première passe, 17 pages plaçaient leur <meta charset> après les premiers 1024 octets, contrairement à la spécification HTML. Treize nommaient également le charset dans l’en-tête, et les quatre autres étaient en UTF-8 valide, donc rien ne s’est cassé, mais un décodeur qui s’appuierait uniquement sur la balise meta l’aurait manqué.
  • Déclarations contradictoires. Un site taïwanais envoyait UTF-8 dans l’en-tête et Big5 dans sa balise meta. L’en-tête l’emporte, comme dans les navigateurs, et dans ce cas, c’était correct.
  • Marques d’ordre des octets. Quatre pages commençaient par une marque d’ordre des octets UTF-8. Certaines sans charset dans l’en-tête étaient quand même décodées en Latin-1 par la bibliothèque, et même là où l’en-tête était correct, la bibliothèque laissait la marque invisible au début du texte.

Règles pratiques

  • Décodez les octets vous-même. Conservez la réponse brute, et si vous archivez les réponses brutes, vous pourrez les redécoder plus tard si vos règles changent.
  • Ne supposez jamais Latin-1. Traitez un charset manquant dans l’en-tête comme « il faut chercher plus loin », pas comme ISO-8859-1.
  • Stockez tout en UTF-8. Décodez une fois en bordure du pipeline, enregistrez quel encodage a été utilisé, et restez en UTF-8 par la suite.
  • Surveillez les caractères de remplacement. Une hausse soudaine de U+FFFD dans un champ est une alarme simple et fiable, et a sa place à côté des vérifications décrites dans la dérive de schéma dans les données scrapées.
  • Normalisez après le décodage. Des caractères corrects peuvent quand même s’écrire de plusieurs façons, comme des chiffres pleine chasse ou des tildes différentes. Normalisez avant de comparer ou de dédupliquer, comme décrit dans normaliser les prix, nombres et dates entre les locales.

En résumé

Presque tout le web est désormais en UTF-8, et les pipelines continuent de le corrompre, parce que l’échec le plus courant n’est pas un encodage exotique mais un comportement par défaut de bibliothèque qui ignore la déclaration propre de la page. Sur notre échantillon, ce comportement par défaut a corrompu environ une page sur cinq, et aucune n’a généré d’erreur.

Décodez à partir des octets, dans l’ordre utilisé par les navigateurs, avec les étiquettes associées comme le font les navigateurs, et enregistrez quelle règle a décidé. Cela transforme un problème de qualité de données invisible en un problème résolu.

Sources et références

  • WHATWG, Encoding Standard, incluant le tableau des étiquettes et l’index Shift_JIS.
  • WHATWG, HTML Standard : parsing HTML documents, sur la détermination de l’encodage des caractères.
  • Tranco, liste Q2K34, agrégeant les classements de domaines du 1 au 30 septembre 2026.
  • charset_normalizer, version 3.5.2, et requests 2.32.5, utilisés dans le test.
  • Pages d’accueil récupérées par Shifter le 1 octobre 2026, à l’aide du code ci-dessus.

Prêt à commencer ?

Essayez les proxies résidentiels de Shifter, 205M+ IPs, 195+ pays, à partir de 0,75 $/GB.

Commencer